Compare commits
39 Commits
0.9.61-beta
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 197012e662 | |||
| e4dac5dd05 | |||
| a86bda59f6 | |||
| 5b1269cbe8 | |||
| c81a0260e2 | |||
| 2b1e6b0c3b | |||
| bff42962ba | |||
| 73e2f184cf | |||
| 0c017c4227 | |||
| 234653b549 | |||
| 23f343f1df | |||
| 88c7c1632e | |||
| 8c9fb0d45e | |||
| e54535f465 | |||
| 46d7c45ca5 | |||
| 9c32e4cbda | |||
| dc0f25aca3 | |||
| 93c78a672c | |||
| 63cae5b0ed | |||
| 81487d1dba | |||
| 6ca7e9e6a6 | |||
| e230cb47e8 | |||
| 9300a0bfb6 | |||
| 3d64115051 | |||
| 2f215ed2e5 | |||
| bda0c0c804 | |||
| bf38004b5e | |||
| f83c5dd440 | |||
| 2548aba840 | |||
| 5803559183 | |||
| 73a786879e | |||
| f4cb5d101e | |||
| 9331b5870f | |||
| dd213ddfd1 | |||
| 0990c61c93 | |||
| 5b64453101 | |||
| cbf2938c9c | |||
| 04643ad686 | |||
| 13cd5a4041 |
@@ -8,5 +8,6 @@ npm-debug.log
|
||||
/audiobooks2
|
||||
/metadata
|
||||
dev.js
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
@@ -5,5 +5,6 @@ node_modules/
|
||||
/audiobooks/
|
||||
/audiobooks2/
|
||||
/metadata/
|
||||
/test/
|
||||
/client/.nuxt/
|
||||
test/
|
||||
/client/.nuxt/
|
||||
/client/dist/
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"github": {
|
||||
"release": true
|
||||
},
|
||||
"npm": {
|
||||
"publish": false
|
||||
}
|
||||
}
|
||||
@@ -61,4 +61,36 @@
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid white;
|
||||
}
|
||||
.triangle-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-bottom: 8px solid transparent;
|
||||
border-top: 8px solid rgb(34,127,35);
|
||||
border-right: 8px solid rgb(34,127,35);
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
#page-wrapper {
|
||||
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
|
||||
}
|
||||
|
||||
.box-shadow-md {
|
||||
box-shadow: 2px 8px 6px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-lg-up {
|
||||
box-shadow: 0px -12px 8px #111111ee;
|
||||
}
|
||||
|
||||
.box-shadow-xl {
|
||||
box-shadow: 2px 14px 8px #111111aa;
|
||||
}
|
||||
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
@@ -27,7 +27,7 @@
|
||||
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10">
|
||||
<span class="material-icons text-3xl">forward_10</span>
|
||||
</div>
|
||||
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
|
||||
<controls-playback-speed-control v-model="playbackRate" @change="playbackRateChanged" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
token() {
|
||||
return this.$store.getters.getToken
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
@@ -130,12 +130,22 @@ export default {
|
||||
},
|
||||
updatePlaybackRate(playbackRate) {
|
||||
if (this.audioEl) {
|
||||
console.log('UpdatePlaybackRate', playbackRate)
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
try {
|
||||
this.audioEl.playbackRate = playbackRate
|
||||
this.audioEl.defaultPlaybackRate = playbackRate
|
||||
} catch (error) {
|
||||
console.error('Update playback rate failed', error)
|
||||
}
|
||||
} else {
|
||||
console.error('No Audio El updatePlaybackRate')
|
||||
}
|
||||
},
|
||||
playbackRateChanged(playbackRate) {
|
||||
this.updatePlaybackRate(playbackRate)
|
||||
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
|
||||
console.error('Failed to update settings', err)
|
||||
})
|
||||
},
|
||||
mousemoveTrack(e) {
|
||||
var offsetX = e.offsetX
|
||||
var time = (offsetX / this.trackWidth) * this.totalDuration
|
||||
@@ -355,7 +365,8 @@ export default {
|
||||
this.hlsInstance = new Hls(hlsOptions)
|
||||
var audio = this.$refs.audio
|
||||
audio.volume = this.volume
|
||||
audio.playbackRate = this.playbackRate
|
||||
audio.defaultPlaybackRate = this.playbackRate
|
||||
|
||||
this.hlsInstance.attachMedia(audio)
|
||||
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||
// console.log('[HLS] MEDIA ATTACHED')
|
||||
@@ -410,17 +421,27 @@ export default {
|
||||
this.set(this.url, startTime, true)
|
||||
},
|
||||
init() {
|
||||
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
|
||||
|
||||
this.audioEl = this.$refs.audio
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
|
||||
this.updatePlaybackRate(settings.playbackRate)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// this.$nextTick(this.init)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'audioplayer')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,21 +2,31 @@
|
||||
<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 class="flex h-full items-center">
|
||||
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
|
||||
<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">AudioBookshelf</h1>
|
||||
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
|
||||
|
||||
<controls-global-search />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
|
||||
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||
<span class="material-icons">settings</span>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn>
|
||||
<ui-btn color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn>
|
||||
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -26,15 +36,17 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
menuItems: [
|
||||
// {
|
||||
// value: 'settings',
|
||||
// text: 'Settings'
|
||||
// },
|
||||
{
|
||||
value: 'account',
|
||||
text: 'Account',
|
||||
to: '/account'
|
||||
},
|
||||
{
|
||||
value: 'logout',
|
||||
text: 'Logout'
|
||||
}
|
||||
]
|
||||
],
|
||||
processingBatchDelete: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -42,10 +54,25 @@ export default {
|
||||
return this.$route.name !== 'index'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isRootUser() {
|
||||
return this.$store.getters['user/getIsRoot']
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
numAudiobooksSelected() {
|
||||
return this.selectedAudiobooks.length
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
isAllSelected() {
|
||||
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
||||
},
|
||||
audiobooksShowing() {
|
||||
return this.$store.getters['audiobooks/getFiltered']()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -56,10 +83,6 @@ export default {
|
||||
this.$router.push('/')
|
||||
}
|
||||
},
|
||||
scan() {
|
||||
console.log('Call Start Init')
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
logout() {
|
||||
this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
@@ -72,9 +95,44 @@ export default {
|
||||
menuAction(action) {
|
||||
if (action === 'logout') {
|
||||
this.logout()
|
||||
} else if (action === 'settings') {
|
||||
// Show settings modal
|
||||
}
|
||||
},
|
||||
cancelSelectionMode() {
|
||||
if (this.processingBatchDelete) return
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
},
|
||||
toggleSelectAll() {
|
||||
if (this.isAllSelected) {
|
||||
this.cancelSelectionMode()
|
||||
} else {
|
||||
var audiobookIds = this.audiobooksShowing.map((a) => a.id)
|
||||
this.$store.commit('setSelectedAudiobooks', audiobookIds)
|
||||
}
|
||||
},
|
||||
batchDeleteClick() {
|
||||
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
|
||||
this.processingBatchDelete = true
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
this.$axios
|
||||
.$post(`/api/audiobooks/delete`, {
|
||||
audiobookIds: this.selectedAudiobooks
|
||||
})
|
||||
.then(() => {
|
||||
this.$toast.success('Batch delete success!')
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch delete failed')
|
||||
console.error('Failed to batch delete', error)
|
||||
this.processingBatchDelete = false
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
batchEditClick() {
|
||||
this.$router.push('/batch')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
@@ -83,7 +141,6 @@ export default {
|
||||
|
||||
<style>
|
||||
#appbar {
|
||||
/* box-shadow: 0px 8px 8px #111111aa; */
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,14 @@
|
||||
<template>
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
|
||||
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
|
||||
<!-- Cover size widget -->
|
||||
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
|
||||
<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">Your Audiobookshelf is empty!</p>
|
||||
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
|
||||
@@ -9,7 +18,7 @@
|
||||
<div :key="index" class="w-full bookshelfRow relative">
|
||||
<div class="flex justify-center items-center">
|
||||
<template v-for="audiobook in shelf">
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
|
||||
</template>
|
||||
</div>
|
||||
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
|
||||
@@ -24,24 +33,51 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
width: 0,
|
||||
bookWidth: 176,
|
||||
booksPerRow: 0,
|
||||
groupedBooks: [],
|
||||
currFilterOrderKey: null
|
||||
currFilterOrderKey: null,
|
||||
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
|
||||
selectedSizeIndex: 3,
|
||||
rowPaddingX: 40
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
},
|
||||
filterOrderKey() {
|
||||
return this.$store.getters['settings/getFilterOrderKey']
|
||||
return this.$store.getters['user/getFilterOrderKey']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
return this.availableSizes[this.selectedSizeIndex]
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.bookCoverWidth / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
bookWidth() {
|
||||
return this.bookCoverWidth + this.paddingX * 2
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
}
|
||||
},
|
||||
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 })
|
||||
},
|
||||
setGroupedBooks() {
|
||||
var groups = []
|
||||
var currentRow = 0
|
||||
@@ -66,6 +102,7 @@ export default {
|
||||
},
|
||||
calculateBookshelf() {
|
||||
this.width = this.$refs.wrapper.clientWidth
|
||||
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
|
||||
var booksPerRow = Math.floor(this.width / this.bookWidth)
|
||||
this.booksPerRow = booksPerRow
|
||||
},
|
||||
@@ -76,6 +113,9 @@ export default {
|
||||
return null
|
||||
},
|
||||
init() {
|
||||
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
|
||||
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
|
||||
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
|
||||
this.calculateBookshelf()
|
||||
},
|
||||
resize() {
|
||||
@@ -88,11 +128,17 @@ export default {
|
||||
console.log('[AudioBookshelf] Audiobooks Updated')
|
||||
this.setGroupedBooks()
|
||||
},
|
||||
settingsUpdated() {
|
||||
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
|
||||
settingsUpdated(settings) {
|
||||
if (this.currFilterOrderKey !== this.filterOrderKey) {
|
||||
this.setGroupedBooks()
|
||||
}
|
||||
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')
|
||||
@@ -100,7 +146,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
|
||||
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
this.$store.dispatch('audiobooks/load')
|
||||
this.init()
|
||||
@@ -108,7 +154,7 @@ export default {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('audiobooks/removeListener', 'bookshelf')
|
||||
this.$store.commit('settings/removeListener', 'bookshelf')
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelf')
|
||||
window.removeEventListener('resize', this.resize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<p class="font-book">{{ numShowing }} Audiobooks</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" />
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" />
|
||||
<span class="px-4 text-sm">by</span>
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
|
||||
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -14,7 +14,8 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
settings: {}
|
||||
settings: {},
|
||||
hasInit: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -30,15 +31,24 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
// Send to server
|
||||
this.$store.commit('settings/setSettings', this.settings)
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
init() {
|
||||
this.settings = { ...this.$store.state.settings.settings }
|
||||
this.settings = { ...this.$store.state.user.settings }
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
for (const key in settings) {
|
||||
this.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
|
||||
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
|
||||
<div class="absolute -top-16 left-4">
|
||||
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
|
||||
</div>
|
||||
@@ -22,6 +22,7 @@
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
audioPlayerReady: false,
|
||||
lastServerUpdateSentSeconds: 0,
|
||||
stream: null
|
||||
}
|
||||
@@ -32,7 +33,7 @@ export default {
|
||||
return 'Logo.png'
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isLoading() {
|
||||
if (!this.streamAudiobook) return false
|
||||
@@ -63,6 +64,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
audioPlayerMounted() {
|
||||
this.audioPlayerReady = true
|
||||
if (this.stream) {
|
||||
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
|
||||
this.openStream()
|
||||
@@ -104,12 +106,12 @@ export default {
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log('[STREAM-CONTAINER] streamOpen', stream)
|
||||
this.openStream()
|
||||
} else {
|
||||
} else if (this.audioPlayerReady) {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
streamClosed(streamId) {
|
||||
if (this.stream && this.stream.id === streamId) {
|
||||
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
|
||||
this.terminateStream()
|
||||
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
|
||||
this.stream = null
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<cards-book-cover :audiobook="audiobook" :width="40" />
|
||||
<div class="flex-grow px-2 searchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.searchCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: calc(40px * 1.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +1,42 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
|
||||
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<!-- New Book Flag -->
|
||||
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
|
||||
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
|
||||
<p class="text-center text-sm">New</p>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 left-0 triangle-right" />
|
||||
</div>
|
||||
|
||||
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons text-5xl">play_circle_filled</span>
|
||||
<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="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
|
||||
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
|
||||
<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">
|
||||
<div v-show="!isSelectionMode" class="h-full flex items-center justify-center">
|
||||
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
|
||||
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</div>
|
||||
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
|
||||
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
|
||||
<span class="material-icons" style="font-size: 16px">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
|
||||
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
|
||||
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -36,6 +49,10 @@ export default {
|
||||
userProgress: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -44,24 +61,67 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isNew() {
|
||||
return this.tags.includes('new')
|
||||
},
|
||||
tags() {
|
||||
return this.audiobook.tags || []
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook.id
|
||||
},
|
||||
isSelectionMode() {
|
||||
return this.$store.getters['getNumAudiobooksSelected']
|
||||
},
|
||||
selectedAudiobooks() {
|
||||
return this.$store.state.selectedAudiobooks
|
||||
},
|
||||
selected() {
|
||||
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
|
||||
},
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
},
|
||||
width() {
|
||||
return 120
|
||||
},
|
||||
height() {
|
||||
return this.width * 1.6
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
paddingX() {
|
||||
return 16 * this.sizeMultiplier
|
||||
},
|
||||
title() {
|
||||
return this.book.title
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
author() {
|
||||
return this.book.author
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL || this.author
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF || this.author
|
||||
},
|
||||
authorFormat() {
|
||||
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
|
||||
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('orderBy')
|
||||
},
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('filterBy')
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
@@ -84,9 +144,22 @@ export default {
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
|
||||
},
|
||||
clickError(e) {
|
||||
e.stopPropagation()
|
||||
this.$router.push(`/audiobook/${this.audiobookId}`)
|
||||
@@ -97,14 +170,15 @@ export default {
|
||||
},
|
||||
editClick() {
|
||||
this.$store.commit('showEditModal', this.audiobook)
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.bookCard {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,19 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<img ref="cover" :src="cover" @error="imageError" class="w-full h-full object-cover" />
|
||||
<div class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
||||
<div class="w-full h-full z-0" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
@@ -26,6 +32,7 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -33,7 +40,13 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false
|
||||
imageFailed: false,
|
||||
showCoverBg: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -50,6 +63,7 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorCleaned() {
|
||||
@@ -58,8 +72,22 @@ export default {
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (!this.cover || this.cover === this.placeholderUrl) return ''
|
||||
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
|
||||
try {
|
||||
var url = new URL(this.cover, document.baseURI)
|
||||
return url.href
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
return ''
|
||||
}
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || '/book_placeholder.jpg'
|
||||
return this.book.cover || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
@@ -81,6 +109,31 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
this.$refs.coverBg.style.backgroundSize = 'cover'
|
||||
this.$refs.coverBg.style.backgroundPosition = 'center'
|
||||
this.$refs.coverBg.style.opacity = 0.25
|
||||
this.$refs.coverBg.style.filter = 'blur(1px)'
|
||||
}
|
||||
},
|
||||
hideCoverBg() {},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - 1.6)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
@@ -73,6 +76,11 @@ export default {
|
||||
text: 'Tag',
|
||||
value: 'tags',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: 'Series',
|
||||
value: 'series',
|
||||
sublist: true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -113,11 +121,19 @@ export default {
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
sublistItems() {
|
||||
return this[this.sublist] || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelected() {
|
||||
this.selected = 'all'
|
||||
this.showMenu = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
},
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="w-64 ml-8 relative">
|
||||
<ui-text-input v-model="search" placeholder="Search.." @input="inputUpdate" @focus="focussed" @blur="blurred" class="w-full h-8 text-sm" />
|
||||
<div class="absolute top-0 right-0 bottom-0 h-full flex items-center px-2 text-gray-400 cursor-pointer" @click="clickClear">
|
||||
<span v-if="!search" class="material-icons" style="font-size: 1.2rem">search</span>
|
||||
<span v-else class="material-icons" style="font-size: 1.2rem">close</span>
|
||||
</div>
|
||||
<div v-show="showMenu && (lastSearch || isTyping)" class="absolute z-10 -mt-px w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li v-if="isTyping" class="py-2 px-2">
|
||||
<p>Typing...</p>
|
||||
</li>
|
||||
<li v-else-if="isFetching" class="py-2 px-2">
|
||||
<p>Fetching...</p>
|
||||
</li>
|
||||
<li v-else-if="!items.length" class="py-2 px-2">
|
||||
<p>No Results</p>
|
||||
</li>
|
||||
<template v-else>
|
||||
<template v-for="item in items">
|
||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
|
||||
<template v-if="item.type === 'audiobook'">
|
||||
<cards-audiobook-search-card :audiobook="item.data" />
|
||||
</template>
|
||||
</li>
|
||||
</template>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showMenu: false,
|
||||
isFocused: false,
|
||||
focusTimeout: null,
|
||||
isTyping: false,
|
||||
isFetching: false,
|
||||
search: null,
|
||||
items: [],
|
||||
searchTimeout: null,
|
||||
lastSearch: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobooks() {
|
||||
return this.$store.state.audiobooks.audiobooks
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focussed() {
|
||||
this.isFocused = true
|
||||
this.showMenu = true
|
||||
},
|
||||
blurred() {
|
||||
this.isFocused = false
|
||||
clearTimeout(this.focusTimeout)
|
||||
this.focusTimeout = setTimeout(() => {
|
||||
this.showMenu = false
|
||||
}, 200)
|
||||
},
|
||||
async runSearch(value) {
|
||||
this.lastSearch = value
|
||||
if (!this.lastSearch) {
|
||||
return
|
||||
}
|
||||
this.isFetching = true
|
||||
var results = await this.$axios.$get(`/api/audiobooks?q=${value}`).catch((error) => {
|
||||
console.error('Search error', error)
|
||||
return []
|
||||
})
|
||||
this.isFetching = false
|
||||
this.items = results.map((res) => {
|
||||
return {
|
||||
id: res.id,
|
||||
data: res,
|
||||
type: 'audiobook'
|
||||
}
|
||||
})
|
||||
},
|
||||
inputUpdate(val) {
|
||||
clearTimeout(this.searchTimeout)
|
||||
if (!val) {
|
||||
this.lastSearch = ''
|
||||
this.isTyping = false
|
||||
return
|
||||
}
|
||||
this.isTyping = true
|
||||
this.searchTimeout = setTimeout(() => {
|
||||
this.isTyping = false
|
||||
this.runSearch(val)
|
||||
}, 1000)
|
||||
},
|
||||
clickedOption(option) {
|
||||
if (option.type === 'audiobook') {
|
||||
this.$router.push(`/audiobook/${option.data.id}`)
|
||||
}
|
||||
},
|
||||
clickClear() {
|
||||
if (this.search) {
|
||||
this.search = null
|
||||
this.items = []
|
||||
this.showMenu = false
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span> -->
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
@@ -42,8 +37,12 @@ export default {
|
||||
value: 'book.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
value: 'book.author'
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
@@ -78,7 +77,8 @@ export default {
|
||||
}
|
||||
},
|
||||
selectedText() {
|
||||
var _sel = this.items.find((i) => i.value === this.selected)
|
||||
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
|
||||
var _sel = this.items.find((i) => i.value === _selected)
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
}
|
||||
|
||||
@@ -8,12 +8,24 @@
|
||||
<div class="arrow-down" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full no-scroll flex">
|
||||
<template v-for="(rate, index) in rates">
|
||||
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm">⨯</span></p>
|
||||
<div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
|
||||
<div class="absolute left-0 top-0 h-full w-7 border-r border-black-300 bg-black-300 rounded-l-lg flex items-center justify-center cursor-pointer" :class="rateIndex === 0 ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="leftArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
|
||||
</div>
|
||||
<div class="overflow-hidden relative" style="width: 220px">
|
||||
<div class="flex items-center h-full absolute top-0 left-0 transition-transform duration-100" :style="{ transform: `translateX(${xPos}px)` }">
|
||||
<template v-for="rate in rates">
|
||||
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border-r" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
|
||||
<div class="w-full h-full flex justify-center items-center">
|
||||
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm">⨯</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute top-0 right-0 h-full w-7 bg-black-300 rounded-r-lg flex items-center justify-center cursor-pointer" :class="rateIndex === rates.length - numVisible ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="rightArrowClick">
|
||||
<span class="material-icons" style="font-size: 1.2rem">chevron_right</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,7 +41,9 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
showMenu: false,
|
||||
rateIndex: 1,
|
||||
numVisible: 5
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -42,7 +56,10 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
|
||||
return [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
|
||||
},
|
||||
xPos() {
|
||||
return -1 * this.rateIndex * 44
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -55,6 +72,12 @@ export default {
|
||||
this.playbackRate = newPlaybackRate
|
||||
if (hasChanged) this.$emit('change', newPlaybackRate)
|
||||
this.showMenu = false
|
||||
},
|
||||
leftArrowClick() {
|
||||
this.rateIndex = Math.max(0, this.rateIndex - 4)
|
||||
},
|
||||
rightArrowClick() {
|
||||
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="800" :height="500" :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>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
|
||||
<ui-text-input-with-label v-model="newUser.password" label="Password" type="password" class="mx-2" />
|
||||
</div>
|
||||
<div class="flex py-2">
|
||||
<div class="px-2">
|
||||
<ui-input-dropdown v-model="newUser.type" label="Account Type" :editable="false" :items="accountTypes" />
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<div class="flex items-center pt-4 px-2">
|
||||
<p class="px-3 font-semibold">Is Active</p>
|
||||
<ui-toggle-switch v-model="newUser.isActive" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
account: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newUser: {},
|
||||
isNew: true,
|
||||
accountTypes: ['guest', 'user', 'admin']
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.isNew ? 'Add New Account' : `Update "${(this.account || {}).username}" Account`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
if (!this.newUser.username) {
|
||||
this.$toast.error('Enter a username')
|
||||
return
|
||||
}
|
||||
if (!this.newUser.password) {
|
||||
this.$toast.error('Must have a password, only root user can have an empty password')
|
||||
return
|
||||
}
|
||||
|
||||
var account = { ...this.newUser }
|
||||
this.processing = true
|
||||
if (this.isNew) {
|
||||
this.$axios
|
||||
.$post('/api/user', account)
|
||||
.then((data) => {
|
||||
this.processing = false
|
||||
if (data.error) {
|
||||
this.$toast.error(`Failed to create account: ${data.error}`)
|
||||
} else {
|
||||
console.log('New Account:', data.user)
|
||||
this.$toast.success('New account created')
|
||||
this.show = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create account', error)
|
||||
this.processing = false
|
||||
this.$toast.success('New account created')
|
||||
})
|
||||
}
|
||||
},
|
||||
toggleActive() {
|
||||
this.newUser.isActive = !this.newUser.isActive
|
||||
},
|
||||
init() {
|
||||
this.isNew = !this.account
|
||||
if (this.account) {
|
||||
this.newUser = {
|
||||
username: this.account.username,
|
||||
password: this.account.password,
|
||||
type: this.account.type,
|
||||
isActive: this.account.isActive
|
||||
}
|
||||
} else {
|
||||
this.newUser = {
|
||||
username: null,
|
||||
password: null,
|
||||
type: 'user',
|
||||
isActive: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -6,10 +6,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<div class="absolute -top-10 left-0 w-full flex">
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
|
||||
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
|
||||
<template v-for="tab in tabs">
|
||||
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
|
||||
<keep-alive>
|
||||
@@ -25,18 +24,48 @@ export default {
|
||||
return {
|
||||
selectedTab: 'details',
|
||||
processing: false,
|
||||
audiobook: null
|
||||
audiobook: null,
|
||||
fetchOnShow: false,
|
||||
tabs: [
|
||||
{
|
||||
id: 'details',
|
||||
title: 'Details',
|
||||
component: 'modals-edit-tabs-details'
|
||||
},
|
||||
{
|
||||
id: 'cover',
|
||||
title: 'Cover',
|
||||
component: 'modals-edit-tabs-cover'
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match',
|
||||
component: 'modals-edit-tabs-match'
|
||||
},
|
||||
{
|
||||
id: 'tracks',
|
||||
title: 'Tracks',
|
||||
component: 'modals-edit-tabs-tracks'
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
title: 'Download',
|
||||
component: 'modals-edit-tabs-download'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
|
||||
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
|
||||
if (this.fetchOnShow) this.fetchFull()
|
||||
return
|
||||
}
|
||||
this.fetchOnShow = false
|
||||
this.audiobook = null
|
||||
this.init()
|
||||
} else {
|
||||
this.$store.commit('audiobooks/removeListener', 'edit-modal')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,11 +80,8 @@ export default {
|
||||
}
|
||||
},
|
||||
tabName() {
|
||||
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
|
||||
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
|
||||
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
|
||||
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
|
||||
return ''
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
},
|
||||
selectedAudiobook() {
|
||||
return this.$store.state.selectedAudiobook || {}
|
||||
@@ -75,7 +101,10 @@ export default {
|
||||
this.selectedTab = tab
|
||||
},
|
||||
audiobookUpdated() {
|
||||
this.fetchFull()
|
||||
if (!this.show) this.fetchOnShow = true
|
||||
else {
|
||||
this.fetchFull()
|
||||
}
|
||||
},
|
||||
init() {
|
||||
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-30 opacity-0">
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
|
||||
<div class="flex">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<div class="relative">
|
||||
<cards-book-cover :audiobook="audiobook" />
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
|
||||
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
|
||||
<span class="material-icons">delete</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow pl-6 pr-2">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center">
|
||||
@@ -10,6 +19,24 @@
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
|
||||
<div class="flex items-center justify-center py-2">
|
||||
<p>{{ localCovers.length }} local image(s)</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center">
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submitSearchForm">
|
||||
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
|
||||
<div class="flex-grow px-1">
|
||||
@@ -23,11 +50,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 overflow-y-scroll mt-2 max-w-full">
|
||||
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
|
||||
<p v-if="!coversFound.length">No Covers Found</p>
|
||||
<template v-for="cover in coversFound">
|
||||
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
|
||||
<div class="h-24 bg-primary" style="width: 60px">
|
||||
<img :src="cover" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -37,6 +67,8 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
@@ -51,14 +83,17 @@ export default {
|
||||
searchAuthor: null,
|
||||
imageUrl: null,
|
||||
coversFound: [],
|
||||
hasSearched: false
|
||||
hasSearched: false,
|
||||
showLocalCovers: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
audiobook: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -73,10 +108,23 @@ export default {
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
otherFiles() {
|
||||
return this.audiobook ? this.audiobook.otherFiles || [] : []
|
||||
},
|
||||
localCovers() {
|
||||
return this.otherFiles
|
||||
.filter((f) => f.filetype === 'image')
|
||||
.map((file) => {
|
||||
var _file = { ...file }
|
||||
_file.localPath = Path.join('local', _file.path)
|
||||
return _file
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
init() {
|
||||
this.showLocalCovers = false
|
||||
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
|
||||
this.coversFound = []
|
||||
this.hasSearched = false
|
||||
@@ -85,10 +133,22 @@ export default {
|
||||
this.searchTitle = this.book.title || ''
|
||||
this.searchAuthor = this.book.author || ''
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.book.cover) {
|
||||
this.imageUrl = ''
|
||||
return
|
||||
}
|
||||
this.updateCover('')
|
||||
},
|
||||
submitForm() {
|
||||
this.updateCover(this.imageUrl)
|
||||
},
|
||||
async updateCover(cover) {
|
||||
if (cover === this.book.cover) {
|
||||
console.warn('Cover has not changed..', cover)
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: {
|
||||
@@ -101,13 +161,12 @@ export default {
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=best&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
|
||||
@@ -21,11 +21,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-4">
|
||||
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
|
||||
@@ -52,11 +66,12 @@ export default {
|
||||
description: null,
|
||||
author: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
genres: []
|
||||
},
|
||||
resettingProgress: false,
|
||||
genres: ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
newTags: [],
|
||||
resettingProgress: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -83,32 +98,38 @@ export default {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.$store.getters['getUserAudiobook'](this.audiobookId)
|
||||
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
},
|
||||
userProgress() {
|
||||
return this.userAudiobook ? this.userAudiobook.progress : 0
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addGenre(genre) {
|
||||
this.genres.push({
|
||||
text: genre,
|
||||
value: genre
|
||||
})
|
||||
},
|
||||
async submitForm() {
|
||||
console.log('Submit form', this.details)
|
||||
if (this.isProcessing) {
|
||||
return
|
||||
}
|
||||
this.isProcessing = true
|
||||
const updatePayload = {
|
||||
book: this.details
|
||||
book: this.details,
|
||||
tags: this.newTags
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
@@ -119,7 +140,10 @@ export default {
|
||||
this.details.author = this.book.author
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
},
|
||||
resetProgress() {
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
|
||||
<div class="w-full border border-black-200 p-4 my-4">
|
||||
<p class="text-center text-lg mb-4 pb-8 border-b border-black-200">
|
||||
<span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in
|
||||
<span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 10 minutes then get deleted.
|
||||
</p>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg">Single audio file</p>
|
||||
<div class="flex-grow" />
|
||||
<div>
|
||||
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
|
||||
<p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p>
|
||||
<p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p>
|
||||
|
||||
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
|
||||
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
|
||||
<div class="w-full h-full flex items-center justify-center">
|
||||
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
|
||||
<p class="w-24 font-mono pl-8 text-right">
|
||||
{{ downloadAmount }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tempDisable: false,
|
||||
isDownloading: false,
|
||||
downloadPercent: '0',
|
||||
downloadAmount: '0 KB'
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
singleAudioDownloadPending(newVal) {
|
||||
if (newVal) {
|
||||
this.tempDisable = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
|
||||
},
|
||||
singleAudioDownload() {
|
||||
return this.downloads.find((d) => d.type === 'singleAudio')
|
||||
},
|
||||
singleAudioDownloadPending() {
|
||||
return this.singleAudioDownload && this.singleAudioDownload.isPending
|
||||
},
|
||||
singleAudioDownloadFailed() {
|
||||
return this.singleAudioDownload && this.singleAudioDownload.isFailed
|
||||
},
|
||||
singleAudioDownloadReady() {
|
||||
return this.singleAudioDownload && this.singleAudioDownload.isReady
|
||||
},
|
||||
singleAudioDownloadExpired() {
|
||||
return this.singleAudioDownload && this.singleAudioDownload.isExpired
|
||||
},
|
||||
zipBundleDownload() {
|
||||
return this.downloads.find((d) => d.type === 'zipBundle')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startSingleAudioDownload() {
|
||||
console.log('Download request received', this.audiobook)
|
||||
|
||||
this.tempDisable = true
|
||||
setTimeout(() => {
|
||||
this.tempDisable = false
|
||||
}, 1000)
|
||||
|
||||
var downloadPayload = {
|
||||
audiobookId: this.audiobook.id,
|
||||
type: 'singleAudio'
|
||||
}
|
||||
this.$root.socket.emit('download', downloadPayload)
|
||||
},
|
||||
downloadWithProgress() {
|
||||
var downloadId = this.singleAudioDownload.id
|
||||
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
|
||||
var filename = this.singleAudioDownload.filename
|
||||
|
||||
this.isDownloading = true
|
||||
|
||||
var request = new XMLHttpRequest()
|
||||
request.responseType = 'blob'
|
||||
request.open('get', downloadUrl, true)
|
||||
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
|
||||
request.send()
|
||||
|
||||
request.onreadystatechange = () => {
|
||||
if (request.readyState === 4) {
|
||||
this.isDownloading = false
|
||||
}
|
||||
if (request.readyState == 4 && request.status == 200) {
|
||||
const url = window.URL.createObjectURL(request.response)
|
||||
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
setTimeout(() => {
|
||||
if (anchor) anchor.remove()
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
request.onerror = (err) => {
|
||||
console.error('Download error', err)
|
||||
this.isDownloading = false
|
||||
}
|
||||
|
||||
request.onprogress = (e) => {
|
||||
const percent_complete = Math.floor((e.loaded / e.total) * 100)
|
||||
this.downloadAmount = this.$bytesPretty(e.loaded)
|
||||
this.downloadPercent = percent_complete
|
||||
|
||||
// const duration = (new Date().getTime() - startTime) / 1000
|
||||
// const bps = e.loaded / duration
|
||||
// const kbps = Math.floor(bps / 1024)
|
||||
// const time = (e.total - e.loaded) / bps
|
||||
// const seconds = Math.floor(time % 60)
|
||||
// const minutes = Math.floor(time / 60)
|
||||
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-show="processing" class="flex h-full items-center justify-center">
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
|
||||
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
|
||||
<p>No Results</p>
|
||||
</div>
|
||||
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
|
||||
@@ -37,11 +37,13 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
audiobookId: null,
|
||||
searchTitle: null,
|
||||
searchAuthor: null,
|
||||
lastSearch: null,
|
||||
provider: 'best',
|
||||
searchResults: []
|
||||
searchResults: [],
|
||||
hasSearched: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -64,7 +66,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
return searchQuery
|
||||
},
|
||||
@@ -90,15 +92,22 @@ export default {
|
||||
})
|
||||
this.searchResults = results
|
||||
this.isProcessing = false
|
||||
this.hasSearched = true
|
||||
},
|
||||
init() {
|
||||
if (this.audiobook.id !== this.audiobookId) {
|
||||
this.searchResults = []
|
||||
this.hasSearched = false
|
||||
this.audiobookId = this.audiobook.id
|
||||
}
|
||||
|
||||
if (!this.audiobook.book || !this.audiobook.book.title) {
|
||||
this.searchTitle = null
|
||||
this.searchAuthor = null
|
||||
return
|
||||
}
|
||||
this.searchTitle = this.audiobook.book.title
|
||||
this.searchAuthor = this.audiobook.book.author || ''
|
||||
this.runSearch()
|
||||
},
|
||||
async selectMatch(match) {
|
||||
this.isProcessing = true
|
||||
@@ -120,7 +129,6 @@ export default {
|
||||
})
|
||||
this.isProcessing = false
|
||||
if (updatedAudiobook) {
|
||||
console.log('Update Successful', updatedAudiobook)
|
||||
this.$toast.success('Update Successful')
|
||||
this.$emit('close')
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
|
||||
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
|
||||
<slot />
|
||||
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
|
||||
<!-- <span class="material-icons animate-spin">refresh</span> -->
|
||||
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -16,7 +22,9 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
small: Boolean
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -24,6 +32,7 @@ export default {
|
||||
computed: {
|
||||
classList() {
|
||||
var list = []
|
||||
if (this.loading) list.push('text-opacity-0')
|
||||
list.push('text-white')
|
||||
list.push(`bg-${this.color}`)
|
||||
if (this.small) {
|
||||
@@ -61,7 +70,10 @@ button.btn::before {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
button.btn:hover::before {
|
||||
button.btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded px-2 py-2">
|
||||
<input ref="input" v-model="textInput" :readonly="!editable" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="input === item" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isFocused: false,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
textInput: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
this.textInput = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput || this.textInput === this.input) {
|
||||
return this.items
|
||||
}
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
},
|
||||
inputFocus() {
|
||||
this.isFocused = true
|
||||
},
|
||||
inputBlur() {
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.input !== this.textInput) {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
submitForm() {
|
||||
var val = this.$cleanString(this.textInput) || null
|
||||
this.input = val
|
||||
if (val && !this.items.includes(val)) {
|
||||
this.$emit('newItem', val)
|
||||
}
|
||||
this.currentSearch = null
|
||||
},
|
||||
clickedOption(e, item) {
|
||||
var newValue = this.input === item ? null : item
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.input = this.$cleanString(newValue) || null
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -12,7 +12,14 @@
|
||||
<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 text-base 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)">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<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">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :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">{{ item.text }}</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
|
||||
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
|
||||
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ $snakeToNormal(item) }}
|
||||
</div>
|
||||
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -13,7 +18,7 @@
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ snakeToNormal(item) }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ $snakeToNormal(item) }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
@@ -47,7 +52,6 @@ export default {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
isTyping: false,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
@@ -71,38 +75,15 @@ export default {
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var normie = this.snakeToNormal(i)
|
||||
var normie = this.$snakeToNormal(i)
|
||||
var iValue = String(normie).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
snakeToNormal(kebab) {
|
||||
if (!kebab) {
|
||||
return 'err'
|
||||
}
|
||||
return String(kebab)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
},
|
||||
normalToSnake(normie) {
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
},
|
||||
setMatchingItems() {
|
||||
if (!this.textInput) {
|
||||
return
|
||||
}
|
||||
this.currentSearch = this.textInput
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.isTyping = true
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
@@ -156,8 +137,10 @@ export default {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, itemValue) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
@@ -169,6 +152,9 @@ export default {
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.showMenu) {
|
||||
@@ -176,10 +162,16 @@ export default {
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
var kebabItem = this.normalToSnake(item)
|
||||
var kebabItem = this.$normalToSnake(item)
|
||||
this.selected.push(kebabItem)
|
||||
this.$emit('addOption', kebabItem)
|
||||
this.$emit('input', this.selected)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
@@ -191,12 +183,12 @@ export default {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.toLowerCase().trim()
|
||||
var cleanedKebab = this.normalToSnake(cleaned)
|
||||
var cleanedKebab = this.$normalToSnake(cleaned)
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned || cleanedKebab === i
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(matchesItem.value)
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -29,8 +29,17 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
focused() {
|
||||
this.$emit('focus')
|
||||
},
|
||||
blurred() {
|
||||
this.$emit('blur')
|
||||
},
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
keyup(e) {
|
||||
this.$emit('keyup', e)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-end" :class="toggleColor" @click="clickToggle">
|
||||
<span class="rounded-full border w-6 h-6 border-black-50 bg-white shadow transform transition-transform duration-100" :class="!toggleValue ? '-translate-x-6' : ''"> </span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
computed: {
|
||||
toggleValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
toggleColor() {
|
||||
return this.toggleValue ? 'bg-success' : 'bg-primary'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickToggle() {
|
||||
console.log('click toggle', this.toggleValue)
|
||||
this.toggleValue = !this.toggleValue
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -10,6 +10,10 @@ export default {
|
||||
text: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@@ -21,11 +25,17 @@ export default {
|
||||
methods: {
|
||||
createTooltip() {
|
||||
var boxChow = this.$refs.box.getBoundingClientRect()
|
||||
var top = boxChow.top
|
||||
var left = boxChow.left + boxChow.width + 4
|
||||
|
||||
var top = 0
|
||||
var left = 0
|
||||
if (this.direction === 'right') {
|
||||
top = boxChow.top
|
||||
left = boxChow.left + boxChow.width + 4
|
||||
} else if (this.direction === 'bottom') {
|
||||
top = boxChow.top + boxChow.height + 4
|
||||
left = boxChow.left
|
||||
}
|
||||
var tooltip = document.createElement('div')
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
|
||||
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
|
||||
tooltip.style.top = top + 'px'
|
||||
tooltip.style.left = left + 'px'
|
||||
tooltip.style.zIndex = 100
|
||||
|
||||
@@ -1,25 +1,47 @@
|
||||
<template>
|
||||
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
|
||||
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
|
||||
<p class="text-lg font-sans" v-html="text" />
|
||||
</div>
|
||||
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
|
||||
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
hasCanceled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isScanning(newVal) {
|
||||
if (newVal) {
|
||||
this.hasCanceled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
text() {
|
||||
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
|
||||
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
|
||||
},
|
||||
isScanning() {
|
||||
return this.isScanningFiles || this.isScanningCovers
|
||||
},
|
||||
isScanningFiles() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
scanProgressKey() {
|
||||
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
|
||||
},
|
||||
scanProgress() {
|
||||
return this.$store.state.scanProgress
|
||||
return this.$store.state[this.scanProgressKey]
|
||||
},
|
||||
scanPercent() {
|
||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
||||
@@ -31,7 +53,12 @@ export default {
|
||||
return this.scanProgress ? this.scanProgress.total : 0
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
cancelScan() {
|
||||
this.hasCanceled = true
|
||||
this.$root.socket.emit('cancel_scan')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
middleware: 'authenticated',
|
||||
data() {
|
||||
return {
|
||||
socket: null
|
||||
@@ -20,17 +21,20 @@ export default {
|
||||
if (this.$store.state.showEditModal) {
|
||||
this.$store.commit('setShowEditModal', false)
|
||||
}
|
||||
if (this.$store.state.selectedAudiobooks) {
|
||||
this.$store.commit('setSelectedAudiobooks', [])
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
connect() {
|
||||
console.log('[SOCKET] Connected')
|
||||
var token = this.$store.getters.getToken
|
||||
var token = this.$store.getters['user/getToken']
|
||||
this.socket.emit('auth', token)
|
||||
},
|
||||
connectError() {},
|
||||
@@ -49,7 +53,8 @@ export default {
|
||||
}
|
||||
}
|
||||
if (payload.user) {
|
||||
this.$store.commit('setUser', payload.user)
|
||||
this.$store.commit('user/setUser', payload.user)
|
||||
this.$store.commit('user/setSettings', payload.user.settings)
|
||||
}
|
||||
},
|
||||
streamOpen(stream) {
|
||||
@@ -81,21 +86,79 @@ export default {
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
},
|
||||
scanComplete() {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
this.$toast.success('Scan Finished')
|
||||
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 (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||
}
|
||||
}
|
||||
},
|
||||
scanStart() {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
scanStart(scanType) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', true)
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
}
|
||||
},
|
||||
scanProgress(progress) {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
scanProgress({ scanType, progress }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setCoverScanProgress', progress)
|
||||
} else {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.id === user.id) {
|
||||
this.$store.commit('setUser', user)
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setSettings', user.settings)
|
||||
}
|
||||
},
|
||||
downloadStarted(download) {
|
||||
var filename = download.filename
|
||||
this.$toast.success(`Preparing download for "${filename}"`)
|
||||
|
||||
download.isPending = true
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadReady(download) {
|
||||
var filename = download.filename
|
||||
this.$toast.success(`Download "${filename}" is ready!`)
|
||||
|
||||
download.isPending = false
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadFailed(download) {
|
||||
var filename = download.filename
|
||||
this.$toast.error(`Download "${filename}" is failed`)
|
||||
|
||||
download.isFailed = true
|
||||
download.isReady = false
|
||||
download.isPending = false
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
downloadKilled(download) {
|
||||
var filename = download.filename
|
||||
this.$toast.error(`Download "${filename}" was terminated`)
|
||||
|
||||
this.$store.commit('downloads/removeDownload', download)
|
||||
},
|
||||
downloadExpired(download) {
|
||||
download.isExpired = true
|
||||
download.isReady = false
|
||||
download.isPending = false
|
||||
this.$store.commit('downloads/addUpdateDownload', download)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -136,21 +199,22 @@ export default {
|
||||
this.socket.on('scan_start', this.scanStart)
|
||||
this.socket.on('scan_complete', this.scanComplete)
|
||||
this.socket.on('scan_progress', this.scanProgress)
|
||||
},
|
||||
checkVersion() {
|
||||
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => {
|
||||
console.log('GOT DATA', data)
|
||||
})
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
if (!this.$store.state.user) {
|
||||
this.$router.replace(`/login?redirect=${this.$route.path}`)
|
||||
|
||||
// Download Listeners
|
||||
this.socket.on('download_started', this.downloadStarted)
|
||||
this.socket.on('download_ready', this.downloadReady)
|
||||
this.socket.on('download_failed', this.downloadFailed)
|
||||
this.socket.on('download_killed', this.downloadKilled)
|
||||
this.socket.on('download_expired', this.downloadExpired)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.initializeSocket()
|
||||
this.checkVersion()
|
||||
|
||||
if (this.$route.query.error) {
|
||||
this.$toast.error(this.$route.query.error)
|
||||
this.$router.replace(this.$route.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,7 @@
|
||||
export default function ({ store, redirect, route }) {
|
||||
// If the user is not authenticated
|
||||
if (!store.state.user.user) {
|
||||
if (route.name === 'batch') return redirect('/login')
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "0.9.6-beta",
|
||||
"version": "1.0.0",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="w-full h-full p-8">
|
||||
<div class="w-full max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl">Account</h1>
|
||||
|
||||
<div class="my-4">
|
||||
<div class="flex -mx-2">
|
||||
<div class="w-2/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="username" label="Username" />
|
||||
</div>
|
||||
<div class="w-1/3 px-2">
|
||||
<ui-text-input-with-label disabled :value="usertype" label="Account Type" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-primary my-4" />
|
||||
|
||||
<p class="mb-4 text-lg">Change Password</p>
|
||||
<form @submit.prevent="submitChangePassword">
|
||||
<ui-text-input-with-label v-model="password" :disabled="changingPassword" type="password" label="Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="newPassword" :disabled="changingPassword" type="password" label="New Password" class="my-2" />
|
||||
<ui-text-input-with-label v-model="confirmPassword" :disabled="changingPassword" type="password" label="Confirm Password" class="my-2" />
|
||||
<div class="flex items-center py-2">
|
||||
<p v-if="isRoot" class="text-error py-2 text-xs">* Root user is the only user that can have an empty password</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn type="submit" :loading="changingPassword" color="success">Submit</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
password: null,
|
||||
newPassword: null,
|
||||
confirmPassword: null,
|
||||
changingPassword: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user.user || null
|
||||
},
|
||||
username() {
|
||||
return this.user.username
|
||||
},
|
||||
usertype() {
|
||||
return this.user.type
|
||||
},
|
||||
isRoot() {
|
||||
return this.usertype === 'root'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetForm() {
|
||||
this.password = null
|
||||
this.newPassword = null
|
||||
this.confirmPassword = null
|
||||
},
|
||||
submitChangePassword() {
|
||||
if (this.newPassword !== this.confirmPassword) {
|
||||
return this.$toast.error('New password and confirm password do not match')
|
||||
}
|
||||
if (this.password === this.newPassword) {
|
||||
return this.$toast.error('Password and New Password cannot be the same')
|
||||
}
|
||||
this.changingPassword = true
|
||||
this.$axios
|
||||
.$patch('/api/user/password', {
|
||||
password: this.password,
|
||||
newPassword: this.newPassword
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.success) {
|
||||
this.$toast.success('Password Changed Successfully')
|
||||
this.resetForm()
|
||||
} else {
|
||||
this.$toast.error(res.error || 'Unknown Error')
|
||||
}
|
||||
this.changingPassword = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
this.$toast.error('Api call failed')
|
||||
this.changingPassword = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
draggable
|
||||
},
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full h-full overflow-y-auto p-8">
|
||||
<div class="flex max-w-6xl mx-auto">
|
||||
<div class="w-52" style="min-width: 208px">
|
||||
@@ -10,19 +10,27 @@
|
||||
</div>
|
||||
<div class="flex-grow px-10">
|
||||
<div class="flex">
|
||||
<h1 class="text-2xl">{{ title }}</h1>
|
||||
<div class="mb-2">
|
||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
<p class="text-gray-300 text-sm my-1">
|
||||
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
|
||||
</p>
|
||||
<div class="flex items-center pt-4">
|
||||
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
|
||||
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
Play
|
||||
<ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream">
|
||||
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</ui-btn>
|
||||
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>
|
||||
|
||||
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
|
||||
|
||||
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
@@ -44,7 +52,9 @@
|
||||
<p class="text-sm mb-2">
|
||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||
</p>
|
||||
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
|
||||
<div>
|
||||
<p v-for="part in invalidParts" :key="part" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
|
||||
@@ -61,7 +71,7 @@
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, params, app, redirect, route }) {
|
||||
if (!store.state.user) {
|
||||
if (!store.state.user.user) {
|
||||
return redirect(`/login?redirect=${route.path}`)
|
||||
}
|
||||
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
|
||||
@@ -82,6 +92,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isDeveloperMode() {
|
||||
return this.$store.state.developerMode
|
||||
},
|
||||
missingPartChunks() {
|
||||
if (this.missingParts === 1) return this.missingParts[0]
|
||||
var chunks = []
|
||||
@@ -128,6 +141,27 @@ export default {
|
||||
author() {
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF
|
||||
},
|
||||
authorTooltipText() {
|
||||
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
|
||||
return txt.join('\n')
|
||||
},
|
||||
series() {
|
||||
return this.book.series || null
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
},
|
||||
seriesText() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
durationPretty() {
|
||||
return this.audiobook.durationPretty
|
||||
},
|
||||
@@ -158,7 +192,7 @@ export default {
|
||||
return this.book.description || 'No Description'
|
||||
},
|
||||
userAudiobooks() {
|
||||
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
|
||||
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
|
||||
},
|
||||
userAudiobook() {
|
||||
return this.userAudiobooks[this.audiobookId] || null
|
||||
@@ -180,6 +214,18 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openRssFeed() {
|
||||
this.$axios
|
||||
.$post('/api/feed', { audiobookId: this.audiobook.id })
|
||||
.then((res) => {
|
||||
console.log('Feed open', res)
|
||||
this.$toast.success('RSS Feed Open')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to open feed')
|
||||
})
|
||||
},
|
||||
startStream() {
|
||||
this.$store.commit('setStreamAudiobook', this.audiobook)
|
||||
this.$root.socket.emit('open_stream', this.audiobook.id)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div ref="page" id="page-wrapper" class="page px-6 pt-6 pb-52 overflow-y-auto" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="flex justify-center flex-wrap">
|
||||
<template v-for="audiobook in audiobookCopies">
|
||||
<div :key="audiobook.id" class="w-full max-w-3xl border border-black-300 p-6 -ml-px -mt-px flex">
|
||||
<div class="w-32">
|
||||
<cards-book-cover :audiobook="audiobook.originalAudiobook" :width="120" />
|
||||
</div>
|
||||
<div class="flex-grow pl-4">
|
||||
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.author" label="Author" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.publishYear" type="number" label="Publish Year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="audiobook.book.series" label="Series" :items="seriesItems" @input="seriesChanged" @newItem="newSeriesItem" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="audiobook.book.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="audiobook.book.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-1/2 px-1">
|
||||
<ui-multi-select v-model="audiobook.book.genres" label="Genres" :items="genres" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-show="isProcessing" class="fixed top-0 left-0 z-50 w-full h-full flex items-center justify-center bg-black bg-opacity-60">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
||||
<div :class="isScrollable ? 'fixed left-0 box-shadow-lg-up bg-primary' : ''" class="w-full h-20 px-4 flex items-center border-t border-bg z-40" :style="{ bottom: streamAudiobook ? '165px' : '0px' }">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" :padding-x="8" class="text-lg" :loading="isProcessing" @click="saveClick">Save</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.state.selectedAudiobooks.length) {
|
||||
return redirect('/')
|
||||
}
|
||||
var audiobooks = store.state.audiobooks.audiobooks.filter((ab) => store.state.selectedAudiobooks.includes(ab.id))
|
||||
return {
|
||||
audiobooks
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessing: false,
|
||||
audiobookCopies: [],
|
||||
isScrollable: false,
|
||||
newSeriesItems: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
tags() {
|
||||
return this.$store.state.audiobooks.tags
|
||||
},
|
||||
series() {
|
||||
return this.$store.state.audiobooks.series
|
||||
},
|
||||
seriesItems() {
|
||||
return [...this.series, ...this.newSeriesItems]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
newSeriesItem(item) {
|
||||
if (!item) return
|
||||
this.newSeriesItems.push(item)
|
||||
},
|
||||
seriesChanged() {
|
||||
this.newSeriesItems = this.newSeriesItems.filter((item) => {
|
||||
return this.audiobookCopies.find((ab) => ab.book.series === item)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.audiobookCopies = this.audiobooks.map((ab) => {
|
||||
var copy = { ...ab }
|
||||
copy.tags = [...ab.tags]
|
||||
copy.book = { ...ab.book }
|
||||
copy.book.genres = [...ab.book.genres]
|
||||
copy.originalAudiobook = ab
|
||||
return copy
|
||||
})
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.page.scrollHeight > this.$refs.page.clientHeight) {
|
||||
this.isScrollable = true
|
||||
}
|
||||
})
|
||||
},
|
||||
saveClick() {
|
||||
this.isProcessing = true
|
||||
|
||||
this.$axios
|
||||
.$post('/api/audiobooks/update', this.audiobookCopies)
|
||||
.then((data) => {
|
||||
this.isProcessing = false
|
||||
if (data.updates) {
|
||||
this.$toast.success(`Successfully updated ${data.updates} audiobooks`)
|
||||
this.$router.replace('/')
|
||||
} else {
|
||||
this.$toast.warning('No updates were necessary')
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to batch update', error)
|
||||
this.$toast.error('Failed to batch update')
|
||||
this.isProcessing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
<template>
|
||||
<div class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||
<div class="w-full max-w-4xl mx-auto">
|
||||
<h1 class="text-2xl mb-2">Config</h1>
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
<div class="p-4 text-center h-20">
|
||||
<p>Nothing much here yet...</p>
|
||||
<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>
|
||||
</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="flex items-center py-4 mb-8">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="scan">Scan</ui-btn>
|
||||
<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">
|
||||
<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 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="flex items-start py-2">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex-grow" />
|
||||
<div class="w-40 flex flex-col">
|
||||
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
|
||||
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
|
||||
</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>
|
||||
|
||||
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
<p class="font-mono">v{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -26,24 +66,168 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
|
||||
|
||||
<modals-account-modal v-model="showAccountModal" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsRoot']) {
|
||||
redirect('/?error=unauthorized')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
isResettingAudiobooks: false,
|
||||
users: [],
|
||||
showAccountModal: false,
|
||||
isDeletingUser: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isScanning() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setDeveloperMode() {
|
||||
var value = !this.$store.state.developerMode
|
||||
this.$store.commit('setDeveloperMode', value)
|
||||
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
|
||||
},
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
clickAddUser() {
|
||||
this.showAccountModal = true
|
||||
// this.$toast.info('Under Construction: User management coming soon.')
|
||||
},
|
||||
loadUsers() {
|
||||
this.$axios
|
||||
.$get('/api/users')
|
||||
.then((users) => {
|
||||
this.users = users
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
})
|
||||
},
|
||||
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?')) {
|
||||
this.isResettingAudiobooks = true
|
||||
this.$axios
|
||||
.$delete('/api/audiobooks')
|
||||
.then(() => {
|
||||
this.isResettingAudiobooks = false
|
||||
this.$toast.success('Successfully reset audiobooks')
|
||||
})
|
||||
.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')
|
||||
})
|
||||
}
|
||||
},
|
||||
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.find((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() {}
|
||||
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>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#accounts {
|
||||
table-layout: fixed;
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#accounts td,
|
||||
#accounts th {
|
||||
border: 1px solid #2e2e2e;
|
||||
padding: 8px 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
#accounts tr:nth-child(even) {
|
||||
background-color: #3a3a3a;
|
||||
}
|
||||
|
||||
#accounts tr:hover {
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
#accounts th {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
background-color: #333;
|
||||
}
|
||||
</style>
|
||||
@@ -7,9 +7,6 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
|
||||
@@ -34,29 +34,24 @@ export default {
|
||||
watch: {
|
||||
user(newVal) {
|
||||
if (newVal) {
|
||||
// if (process.env.NODE_ENV !== 'production') {
|
||||
if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
this.$router.replace('/')
|
||||
}
|
||||
|
||||
// } else {
|
||||
// window.location.reload()
|
||||
// }
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.state.user
|
||||
return this.$store.state.user.user
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async submitForm() {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
// var uri = `${process.env.serverUrl}/auth`
|
||||
|
||||
var payload = {
|
||||
username: this.username,
|
||||
password: this.password || ''
|
||||
@@ -71,7 +66,7 @@ export default {
|
||||
} else if (authRes.error) {
|
||||
this.error = authRes.error
|
||||
} else {
|
||||
this.$store.commit('setUser', authRes.user)
|
||||
this.$store.commit('user/setUser', authRes.user)
|
||||
}
|
||||
this.processing = false
|
||||
},
|
||||
@@ -90,7 +85,7 @@ export default {
|
||||
}
|
||||
})
|
||||
.then((res) => {
|
||||
this.$store.commit('setUser', res.user)
|
||||
this.$store.commit('user/setUser', res.user)
|
||||
this.processing = false
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function ({ $axios, store }) {
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
var bearerToken = store.state.user ? store.state.user.token : null
|
||||
var bearerToken = store.state.user.user ? store.state.user.user.token : null
|
||||
// console.log('Bearer token', bearerToken)
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
|
||||
@@ -38,6 +38,92 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$snakeToNormal = (snake) => {
|
||||
if (!snake) {
|
||||
return ''
|
||||
}
|
||||
return String(snake)
|
||||
.split('_')
|
||||
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
Vue.prototype.$normalToSnake = (normie) => {
|
||||
if (!normie) return ''
|
||||
return normie
|
||||
.trim()
|
||||
.split(' ')
|
||||
.map((t) => t.toLowerCase())
|
||||
.join('_')
|
||||
}
|
||||
|
||||
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
||||
const getCharCode = (char) => availableChars.indexOf(char)
|
||||
const getCharFromCode = (code) => availableChars[Number(code)] || -1
|
||||
const cleanChar = (char) => getCharCode(char) < 0 ? '?' : char
|
||||
|
||||
Vue.prototype.$cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
cleaned += cleanChar(str[i])
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
Vue.prototype.$stringToCode = (str) => {
|
||||
if (!str) return ''
|
||||
var numcode = [...str].map(s => {
|
||||
return String(getCharCode(s)).padStart(2, '0')
|
||||
}).join('')
|
||||
return BigInt(numcode).toString(36)
|
||||
}
|
||||
|
||||
Vue.prototype.$codeToString = (code) => {
|
||||
if (!code) return ''
|
||||
var numcode = ''
|
||||
try {
|
||||
numcode = [...code].reduce((acc, curr) => {
|
||||
return BigInt(parseInt(curr, 36)) + BigInt(36) * acc
|
||||
}, 0n)
|
||||
} catch (err) {
|
||||
console.error('numcode fialed', code, err)
|
||||
}
|
||||
var numcodestr = String(numcode)
|
||||
|
||||
var remainder = numcodestr.length % 2
|
||||
numcodestr = numcodestr.padStart(numcodestr.length - 1 + remainder, '0')
|
||||
|
||||
var finalform = ''
|
||||
var numChunks = Math.floor(numcodestr.length / 2)
|
||||
var remaining = numcodestr
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
var chunk = remaining.slice(0, 2)
|
||||
remaining = remaining.slice(2)
|
||||
finalform += getCharFromCode(chunk)
|
||||
}
|
||||
return finalform
|
||||
}
|
||||
|
||||
function cleanString(str, availableChars) {
|
||||
var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < _str.length; i++) {
|
||||
cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export const cleanFilterString = (str) => {
|
||||
var _str = str.toLowerCase().replace(/ /g, '_')
|
||||
_str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
|
||||
return _str
|
||||
}
|
||||
|
||||
function loadImageBlob(uri) {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import Vue from "vue";
|
||||
import Toast from "vue-toastification";
|
||||
// Import the CSS or use your own!
|
||||
import "vue-toastification/dist/index.css";
|
||||
|
||||
const options = {
|
||||
|
||||
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -1,4 +1,5 @@
|
||||
import { sort } from '@/assets/fastSort'
|
||||
import { cleanFilterString } from '@/plugins/init.client'
|
||||
|
||||
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
|
||||
|
||||
@@ -6,31 +7,29 @@ export const state = () => ({
|
||||
audiobooks: [],
|
||||
listeners: [],
|
||||
genres: [...STANDARD_GENRES],
|
||||
tags: []
|
||||
tags: [],
|
||||
series: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getFiltered: (state, getters, rootState) => () => {
|
||||
var filtered = state.audiobooks
|
||||
var settings = rootState.settings.settings || {}
|
||||
var settings = rootState.user.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
var filterByParts = filterBy.split('.')
|
||||
if (filterByParts.length > 1) {
|
||||
var primary = filterByParts[0]
|
||||
var secondary = filterByParts[1]
|
||||
if (primary === 'genres') {
|
||||
filtered = filtered.filter(ab => {
|
||||
return ab.book && ab.book.genres.includes(secondary)
|
||||
})
|
||||
} else if (primary === 'tags') {
|
||||
filtered = filtered.filter(ab => ab.tags.includes(secondary))
|
||||
}
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filter = filterBy.replace(`${group}.`, '')
|
||||
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||
}
|
||||
// TODO: Add filters
|
||||
return filtered
|
||||
},
|
||||
getFilteredAndSorted: (state, getters, rootState) => () => {
|
||||
var settings = rootState.settings.settings
|
||||
var settings = rootState.user.settings
|
||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||
|
||||
var filtered = getters.getFiltered()
|
||||
@@ -38,11 +37,19 @@ export const getters = {
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||
})
|
||||
},
|
||||
getUniqueAuthors: (state) => {
|
||||
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
|
||||
return [...new Set(_authors)]
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
load({ commit }) {
|
||||
load({ commit, rootState }) {
|
||||
if (!rootState.user || !rootState.user.user) {
|
||||
console.error('audiobooks/load - User not set')
|
||||
return
|
||||
}
|
||||
this.$axios
|
||||
.$get(`/api/audiobooks`)
|
||||
.then((data) => {
|
||||
@@ -65,6 +72,7 @@ export const mutations = {
|
||||
genres = genres.concat(ab.book.genres)
|
||||
})
|
||||
state.genres = [...new Set(genres)] // Remove Duplicates
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// TAGS
|
||||
var tags = []
|
||||
@@ -72,6 +80,16 @@ export const mutations = {
|
||||
tags = tags.concat(ab.tags)
|
||||
})
|
||||
state.tags = [...new Set(tags)] // Remove Duplicates
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
// SERIES
|
||||
var series = []
|
||||
audiobooks.forEach((ab) => {
|
||||
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
|
||||
series.push(ab.book.series)
|
||||
})
|
||||
state.series = series
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
|
||||
state.audiobooks = audiobooks
|
||||
state.listeners.forEach((listener) => {
|
||||
@@ -80,19 +98,34 @@ export const mutations = {
|
||||
},
|
||||
addUpdate(state, audiobook) {
|
||||
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
|
||||
var origAudiobook = null
|
||||
if (index >= 0) {
|
||||
origAudiobook = { ...state.audiobooks[index] }
|
||||
state.audiobooks.splice(index, 1, audiobook)
|
||||
} else {
|
||||
state.audiobooks.push(audiobook)
|
||||
}
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
var newGenres = []
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!state.genres.includes(genre)) newGenres.push(genre)
|
||||
})
|
||||
if (newGenres.length) state.genres = state.genres.concat(newGenres)
|
||||
if (newGenres.length) {
|
||||
state.genres = state.genres.concat(newGenres)
|
||||
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
|
||||
state.series.push(audiobook.book.series)
|
||||
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
|
||||
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
|
||||
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -100,8 +133,10 @@ export const mutations = {
|
||||
audiobook.tags.forEach((tag) => {
|
||||
if (!state.tags.includes(tag)) newTags.push(tag)
|
||||
})
|
||||
if (newTags.length) state.tags = state.tags.concat(newTags)
|
||||
|
||||
if (newTags.length) {
|
||||
state.tags = state.tags.concat(newTags)
|
||||
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
|
||||
}
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
@@ -112,8 +147,8 @@ export const mutations = {
|
||||
remove(state, audiobook) {
|
||||
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
|
||||
|
||||
// GENRES
|
||||
if (audiobook.book) {
|
||||
// GENRES
|
||||
audiobook.book.genres.forEach((genre) => {
|
||||
if (!STANDARD_GENRES.includes(genre)) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => {
|
||||
@@ -125,6 +160,15 @@ export const mutations = {
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SERIES
|
||||
if (audiobook.book.series) {
|
||||
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
|
||||
if (!isInOtherAB) {
|
||||
// Series not used in any other audiobook - remove it
|
||||
state.series = state.series.filter(s => s !== audiobook.book.series)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TAGS
|
||||
@@ -138,7 +182,6 @@ export const mutations = {
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
state.listeners.forEach((listener) => {
|
||||
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
|
||||
listener.meth()
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
|
||||
export const state = () => ({
|
||||
downloads: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getDownloads: (state) => (audiobookId) => {
|
||||
return state.downloads.filter(d => d.audiobookId === audiobookId)
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
addUpdateDownload(state, download) {
|
||||
// Remove older downloads of matching type
|
||||
state.downloads = state.downloads.filter(d => {
|
||||
if (d.id !== download.id && d.type === download.type) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
var index = state.downloads.findIndex(d => d.id === download.id)
|
||||
if (index >= 0) {
|
||||
state.downloads.splice(index, 1, download)
|
||||
} else {
|
||||
state.downloads.push(download)
|
||||
}
|
||||
},
|
||||
removeDownload(state, download) {
|
||||
state.downloads = state.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,29 @@
|
||||
import Vue from 'vue'
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
streamAudiobook: null,
|
||||
showEditModal: false,
|
||||
selectedAudiobook: null,
|
||||
playOnLoad: false,
|
||||
isScanning: false,
|
||||
scanProgress: null
|
||||
isScanningCovers: false,
|
||||
scanProgress: null,
|
||||
coverScanProgress: null,
|
||||
developerMode: false,
|
||||
selectedAudiobooks: [],
|
||||
processingBatch: false
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
getIsAudiobookSelected: state => audiobookId => {
|
||||
return !!state.selectedAudiobooks.includes(audiobookId)
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
}
|
||||
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
export const actions = {}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user.token) {
|
||||
localStorage.setItem('token', user.token)
|
||||
}
|
||||
},
|
||||
setStreamAudiobook(state, audiobook) {
|
||||
state.playOnLoad = true
|
||||
state.streamAudiobook = audiobook
|
||||
@@ -56,8 +51,31 @@ export const mutations = {
|
||||
setIsScanning(state, isScanning) {
|
||||
state.isScanning = isScanning
|
||||
},
|
||||
setScanProgress(state, progress) {
|
||||
if (progress > 0) state.isScanning = true
|
||||
state.scanProgress = progress
|
||||
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
|
||||
},
|
||||
toggleAudiobookSelected(state, audiobookId) {
|
||||
if (state.selectedAudiobooks.includes(audiobookId)) {
|
||||
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
|
||||
} else {
|
||||
state.selectedAudiobooks.push(audiobookId)
|
||||
}
|
||||
},
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
|
||||
export const state = () => ({
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all'
|
||||
},
|
||||
|
||||
listeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setSettings(state, settings) {
|
||||
state.settings = {
|
||||
...settings
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
|
||||
export const state = () => ({
|
||||
user: null,
|
||||
settings: {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
bookshelfCoverSize: 120
|
||||
},
|
||||
settingsListeners: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
getIsRoot: (state) => state.user && state.user.type === 'root',
|
||||
getToken: (state) => {
|
||||
return state.user ? state.user.token : null
|
||||
},
|
||||
getUserAudiobook: (state) => (audiobookId) => {
|
||||
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
|
||||
},
|
||||
getUserSetting: (state) => (key) => {
|
||||
return state.settings ? state.settings[key] || null : null
|
||||
},
|
||||
getFilterOrderKey: (state) => {
|
||||
return Object.values(state.settings).join('-')
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
updateUserSettings({ commit }, payload) {
|
||||
var updatePayload = {
|
||||
...payload
|
||||
}
|
||||
return this.$axios.$patch('/api/user/settings', updatePayload).then((result) => {
|
||||
if (result.success) {
|
||||
commit('setSettings', result.settings)
|
||||
console.log('Settings updated', result.settings)
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update settings', error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const mutations = {
|
||||
setUser(state, user) {
|
||||
state.user = user
|
||||
if (user) {
|
||||
if (user.token) localStorage.setItem('token', user.token)
|
||||
console.log('setUser', user.username)
|
||||
} else {
|
||||
localStorage.removeItem('token')
|
||||
console.warn('setUser cleared')
|
||||
}
|
||||
},
|
||||
setSettings(state, settings) {
|
||||
if (!settings) return
|
||||
|
||||
var hasChanges = false
|
||||
for (const key in settings) {
|
||||
if (state.settings[key] !== settings[key]) {
|
||||
hasChanges = true
|
||||
state.settings[key] = settings[key]
|
||||
}
|
||||
}
|
||||
if (hasChanges) {
|
||||
state.settingsListeners.forEach((listener) => {
|
||||
listener.meth(state.settings)
|
||||
})
|
||||
}
|
||||
},
|
||||
addSettingsListener(state, listener) {
|
||||
var index = state.settingsListeners.findIndex(l => l.id === listener.id)
|
||||
if (index >= 0) state.settingsListeners.splice(index, 1, listener)
|
||||
else state.settingsListeners.push(listener)
|
||||
},
|
||||
removeSettingsListener(state, listenerId) {
|
||||
state.settingsListeners = state.settingsListeners.filter(l => l.id !== listenerId)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ module.exports = {
|
||||
purge: {
|
||||
options: {
|
||||
safelist: [
|
||||
'bg-success'
|
||||
'bg-success',
|
||||
'bg-red-600'
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -16,13 +17,13 @@ module.exports = {
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#262626',
|
||||
primary: '#232323',
|
||||
accent: '#1ad691',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
successDark: '#3b8a3e',
|
||||
warning: '#FB8C00',
|
||||
darkgreen: 'rgb(34,127,35)',
|
||||
'black-50': '#bbbbbb',
|
||||
'black-100': '#666666',
|
||||
'black-200': '#555555',
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
<MyIP/>
|
||||
<Shell>sh</Shell>
|
||||
<Privileged>false</Privileged>
|
||||
<Support>https://hub.docker.com/r/advplyr/audiobookshelf/</Support>
|
||||
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
|
||||
<Project>https://github.com/advplyr/audiobookshelf</Project>
|
||||
<Overview>Audiobook manager and player</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books Status:Beta<</Category>
|
||||
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
|
||||
<Category>MediaApp:Books MediaServer:Books Status:Beta</Category>
|
||||
<WebUI>http://[IP]:[PORT:80]</WebUI>
|
||||
<TemplateURL/>
|
||||
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
|
||||
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
|
||||
<ExtraParams/>
|
||||
<PostArgs/>
|
||||
@@ -20,7 +20,7 @@
|
||||
<DateInstalled>1629238508</DateInstalled>
|
||||
<DonateText/>
|
||||
<DonateLink/>
|
||||
<Description>Audiobook manager and player</Description>
|
||||
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
|
||||
<Networking>
|
||||
<Mode>bridge</Mode>
|
||||
<Publish>
|
||||
|
||||
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.1 MiB |
@@ -11,6 +11,7 @@ if (isDev) {
|
||||
process.env.CONFIG_PATH = devEnv.ConfigPath
|
||||
process.env.METADATA_PATH = devEnv.MetadataPath
|
||||
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
|
||||
process.env.FFMPEG_PATH = devEnv.FFmpegPath
|
||||
}
|
||||
|
||||
const PORT = process.env.PORT || 80
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "0.9.61-beta",
|
||||
"version": "1.0.0",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "node index.js",
|
||||
"start": "node index.js",
|
||||
"release": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks",
|
||||
"release-dry": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks --dry-run"
|
||||
"start": "node index.js"
|
||||
},
|
||||
"author": "advplyr",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^0.21.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"chokidar": "^3.5.2",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"express": "^4.17.1",
|
||||
"fluent-ffmpeg": "^2.1.2",
|
||||
"fs-extra": "^10.0.0",
|
||||
"ip": "^1.1.5",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"libgen": "^2.1.0",
|
||||
"njodb": "^0.4.20",
|
||||
"node-dir": "^0.1.17",
|
||||
"socket.io": "^4.1.3"
|
||||
"podcast": "^1.3.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"watcher": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dotenv-cli": "^4.0.0",
|
||||
"release-it": "^14.11.5"
|
||||
}
|
||||
}
|
||||
"devDependencies": {}
|
||||
}
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
|
||||
|
||||
**Currently in early beta**
|
||||
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
|
||||
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_bookshelf.png" />
|
||||
**Free & open source Android/iOS app is in development**
|
||||
|
||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
|
||||
|
||||
#### Folder Structures Supported:
|
||||
@@ -19,14 +21,15 @@ Title can start with the publish year like so:
|
||||
```
|
||||
|
||||
|
||||
#### There is still a lot to do:
|
||||
#### Features coming soon:
|
||||
|
||||
* Adding new audiobooks require pressing Scan button again (on settings page)
|
||||
* Matching is all manual now and only using 1 source (openlibrary)
|
||||
* Auto add and update audiobooks (currently you need to press scan)
|
||||
* User permissions & editing users
|
||||
* Support different views to see more details of each audiobook
|
||||
* Then comes the mobile app..
|
||||
* Option to download all files in a zip file
|
||||
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
|
||||
|
||||
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -36,8 +39,6 @@ Built to run in Docker for now (also on Unraid server Community Apps)
|
||||
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
|
||||
```
|
||||
|
||||
<img alt="Screenshot3" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to help out
|
||||
@@ -1,12 +1,16 @@
|
||||
const express = require('express')
|
||||
const Logger = require('./Logger')
|
||||
const User = require('./objects/User')
|
||||
const { isObject } = require('./utils/index')
|
||||
|
||||
class ApiController {
|
||||
constructor(db, scanner, auth, streamManager, emitter) {
|
||||
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) {
|
||||
this.db = db
|
||||
this.scanner = scanner
|
||||
this.auth = auth
|
||||
this.streamManager = streamManager
|
||||
this.rssFeeds = rssFeeds
|
||||
this.downloadManager = downloadManager
|
||||
this.emitter = emitter
|
||||
|
||||
this.router = express()
|
||||
@@ -17,8 +21,11 @@ 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.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
|
||||
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
|
||||
|
||||
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
|
||||
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
|
||||
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
|
||||
@@ -27,11 +34,20 @@ class ApiController {
|
||||
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
|
||||
this.router.patch('/match/:id', this.match.bind(this))
|
||||
|
||||
this.router.get('/users', this.getUsers.bind(this))
|
||||
this.router.post('/user', this.createUser.bind(this))
|
||||
this.router.delete('/user/:id', this.deleteUser.bind(this))
|
||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
|
||||
this.router.patch('/user/password', this.userChangePassword.bind(this))
|
||||
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
||||
|
||||
this.router.post('/authorize', this.authorize.bind(this))
|
||||
|
||||
this.router.get('/genres', this.getGenres.bind(this))
|
||||
|
||||
this.router.post('/feed', this.openRssFeed.bind(this))
|
||||
|
||||
this.router.get('/download/:id', this.download.bind(this))
|
||||
}
|
||||
|
||||
find(req, res) {
|
||||
@@ -39,7 +55,6 @@ class ApiController {
|
||||
}
|
||||
|
||||
findCovers(req, res) {
|
||||
console.log('Find covers', req.query)
|
||||
this.scanner.findCovers(req, res)
|
||||
}
|
||||
|
||||
@@ -57,9 +72,22 @@ class ApiController {
|
||||
}
|
||||
|
||||
getAudiobooks(req, res) {
|
||||
Logger.info('Get Audiobooks')
|
||||
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
||||
res.json(audiobooksMinified)
|
||||
var audiobooks = []
|
||||
if (req.query.q) {
|
||||
audiobooks = this.db.audiobooks.filter(ab => {
|
||||
return ab.isSearchMatch(req.query.q)
|
||||
}).map(ab => ab.toJSONMinified())
|
||||
} else {
|
||||
audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified())
|
||||
}
|
||||
res.json(audiobooks)
|
||||
}
|
||||
|
||||
async deleteAllAudiobooks(req, res) {
|
||||
Logger.info('Removing all Audiobooks')
|
||||
var success = await this.db.recreateAudiobookDb()
|
||||
if (success) res.sendStatus(200)
|
||||
else res.sendStatus(500)
|
||||
}
|
||||
|
||||
getAudiobook(req, res) {
|
||||
@@ -68,10 +96,7 @@ class ApiController {
|
||||
res.json(audiobook.toJSONExpanded())
|
||||
}
|
||||
|
||||
async deleteAudiobook(req, res) {
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
async handleDeleteAudiobook(audiobook) {
|
||||
// Remove audiobook from users
|
||||
for (let i = 0; i < this.db.users.length; i++) {
|
||||
var user = this.db.users[i]
|
||||
@@ -94,12 +119,66 @@ class ApiController {
|
||||
}
|
||||
}
|
||||
|
||||
var audiobookJSON = audiobook.toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', audiobook.id)
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
}
|
||||
|
||||
this.emitter('audiobook_removed', audiobook.toJSONMinified())
|
||||
async deleteAudiobook(req, res) {
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
|
||||
await this.handleDeleteAudiobook(audiobook)
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchDeleteAudiobooks(req, res) {
|
||||
var { audiobookIds } = req.body
|
||||
if (!audiobookIds || !audiobookIds.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id))
|
||||
if (!audiobooksToDelete.length) {
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
for (let i = 0; i < audiobooksToDelete.length; i++) {
|
||||
Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`)
|
||||
await this.handleDeleteAudiobook(audiobooksToDelete[i])
|
||||
}
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
async batchUpdateAudiobooks(req, res) {
|
||||
var audiobooks = req.body
|
||||
if (!audiobooks || !audiobooks.length) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
|
||||
var audiobooksUpdated = 0
|
||||
audiobooks = audiobooks.map((ab) => {
|
||||
var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id)
|
||||
if (!_ab) return null
|
||||
var hasUpdated = _ab.update(ab)
|
||||
if (!hasUpdated) return null
|
||||
audiobooksUpdated++
|
||||
return _ab
|
||||
}).filter(ab => ab)
|
||||
|
||||
if (audiobooksUpdated) {
|
||||
Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`)
|
||||
for (let i = 0; i < audiobooks.length; i++) {
|
||||
await this.db.updateAudiobook(audiobooks[i])
|
||||
this.emitter('audiobook_updated', audiobooks[i].toJSONMinified())
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
updates: audiobooksUpdated
|
||||
})
|
||||
}
|
||||
|
||||
async updateAudiobookTracks(req, res) {
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
@@ -142,6 +221,11 @@ class ApiController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
getUsers(req, res) {
|
||||
if (req.user.type !== 'root') return res.sendStatus(403)
|
||||
return res.json(this.db.users.map(u => u.toJSONForBrowser()))
|
||||
}
|
||||
|
||||
async resetUserAudiobookProgress(req, res) {
|
||||
req.user.resetAudiobookProgress(req.params.id)
|
||||
await this.db.updateEntity('user', req.user)
|
||||
@@ -149,6 +233,105 @@ class ApiController {
|
||||
res.sendStatus(200)
|
||||
}
|
||||
|
||||
userChangePassword(req, res) {
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
async openRssFeed(req, res) {
|
||||
var audiobookId = req.body.audiobookId
|
||||
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
||||
if (!audiobook) return res.sendStatus(404)
|
||||
var feed = await this.rssFeeds.openFeed(audiobook)
|
||||
console.log('Feed open', feed)
|
||||
res.json(feed)
|
||||
}
|
||||
|
||||
async userUpdateSettings(req, res) {
|
||||
var settingsUpdate = req.body
|
||||
if (!settingsUpdate || !isObject(settingsUpdate)) {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var madeUpdates = req.user.updateSettings(settingsUpdate)
|
||||
if (madeUpdates) {
|
||||
await this.db.updateEntity('user', req.user)
|
||||
}
|
||||
return res.json({
|
||||
success: true,
|
||||
settings: req.user.settings
|
||||
})
|
||||
}
|
||||
|
||||
async createUser(req, res) {
|
||||
var account = req.body
|
||||
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
account.pash = await this.auth.hashPass(account.password)
|
||||
delete account.password
|
||||
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)
|
||||
if (success) {
|
||||
this.emitter('user_added', newUser)
|
||||
res.json({
|
||||
user: newUser.toJSONForBrowser()
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Failed to save new user'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async deleteUser(req, res) {
|
||||
if (req.params.id === 'root') {
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
if (req.user.id === req.params.id) {
|
||||
Logger.error('Attempting to delete themselves...')
|
||||
return res.sendStatus(500)
|
||||
}
|
||||
var user = this.db.users.find(u => u.id === req.params.id)
|
||||
if (!user) {
|
||||
Logger.error('User not found')
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Todo: check if user is logged in and cancel streams
|
||||
|
||||
var userJson = user.toJSONForBrowser()
|
||||
await this.db.removeEntity('user', user.id)
|
||||
this.emitter('user_removed', userJson)
|
||||
res.json({
|
||||
success: true
|
||||
})
|
||||
}
|
||||
|
||||
async download(req, res) {
|
||||
var downloadId = req.params.id
|
||||
Logger.info('Download Request', downloadId)
|
||||
var download = this.downloadManager.getDownload(downloadId)
|
||||
if (!download) {
|
||||
Logger.error('Download request not found', downloadId)
|
||||
return res.sendStatus(404)
|
||||
}
|
||||
|
||||
var options = {
|
||||
headers: {
|
||||
// 'Content-Disposition': `attachment; filename=${download.filename}`,
|
||||
'Content-Type': download.mimeType
|
||||
// 'Content-Length': download.size
|
||||
}
|
||||
}
|
||||
Logger.info('Starting Download', options, 'SIZE', download.size)
|
||||
res.download(download.fullPath, download.filename, options, (err) => {
|
||||
if (err) {
|
||||
Logger.error('Download Error', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getGenres(req, res) {
|
||||
res.json({
|
||||
genres: this.db.getGenres()
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
const { bytesPretty, elapsedPretty } = require('./utils/fileUtils')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
|
||||
class Audiobook {
|
||||
constructor(audiobook = null) {
|
||||
this.id = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
this.invalidParts = []
|
||||
|
||||
this.audioFiles = []
|
||||
this.otherFiles = []
|
||||
|
||||
this.tags = []
|
||||
this.book = null
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
}
|
||||
|
||||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => {
|
||||
return new AudioTrack(track)
|
||||
})
|
||||
this.missingParts = audiobook.missingParts
|
||||
this.invalidParts = audiobook.invalidParts
|
||||
|
||||
this.audioFiles = audiobook.audioFiles
|
||||
this.otherFiles = audiobook.otherFiles
|
||||
|
||||
this.tags = audiobook.tags
|
||||
if (audiobook.book) {
|
||||
this.book = new Book(audiobook.book)
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
}
|
||||
|
||||
get cover() {
|
||||
return this.book ? this.book.cover : ''
|
||||
}
|
||||
|
||||
get author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
|
||||
get totalDuration() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.duration)
|
||||
return total
|
||||
}
|
||||
|
||||
get totalSize() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.size)
|
||||
return total
|
||||
}
|
||||
|
||||
get sizePretty() {
|
||||
return bytesPretty(this.totalSize)
|
||||
}
|
||||
|
||||
get durationPretty() {
|
||||
return elapsedPretty(this.totalDuration)
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
|
||||
tracksToJSON() {
|
||||
if (!this.tracks || !this.tracks.length) return []
|
||||
return this.tracks.map(t => t.toJSON())
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: this.audioFiles,
|
||||
otherFiles: this.otherFiles
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
duration: this.totalDuration,
|
||||
size: this.totalSize,
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numTracks: this.tracks.length
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
duration: this.totalDuration,
|
||||
durationPretty: this.durationPretty,
|
||||
size: this.totalSize,
|
||||
sizePretty: this.sizePretty,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: this.audioFiles,
|
||||
otherFiles: this.otherFiles,
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON()
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
|
||||
this.otherFiles = data.otherFiles || []
|
||||
this.setBook(data)
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
||||
addTrack(trackData) {
|
||||
var track = new AudioTrack()
|
||||
track.setData(trackData)
|
||||
this.tracks.push(track)
|
||||
return track
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
||||
this.tags = payload.tags
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.book) {
|
||||
if (!this.book) {
|
||||
this.setBook(payload.book)
|
||||
hasUpdates = true
|
||||
} else if (this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateAudioTracks(files) {
|
||||
var index = 1
|
||||
this.audioFiles = files.map((file) => {
|
||||
file.manuallyVerified = true
|
||||
file.invalid = false
|
||||
file.error = null
|
||||
file.index = index++
|
||||
return file
|
||||
})
|
||||
this.tracks = []
|
||||
this.invalidParts = []
|
||||
this.missingParts = []
|
||||
this.audioFiles.forEach((file) => {
|
||||
this.addTrack(file)
|
||||
})
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -2,7 +2,6 @@ const bcrypt = require('bcryptjs')
|
||||
const jwt = require('jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
|
||||
class Auth {
|
||||
constructor(db) {
|
||||
this.db = db
|
||||
@@ -42,7 +41,7 @@ class Auth {
|
||||
const authHeader = req.headers['authorization']
|
||||
const token = authHeader && authHeader.split(' ')[1]
|
||||
if (token == null) {
|
||||
Logger.error('Api called without a token')
|
||||
Logger.error('Api called without a token', req.path)
|
||||
return res.sendStatus(401)
|
||||
}
|
||||
|
||||
@@ -75,6 +74,10 @@ class Auth {
|
||||
verifyToken(token) {
|
||||
return new Promise((resolve) => {
|
||||
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
|
||||
if (!payload || err) {
|
||||
Logger.error('JWT Verify Token Failed', err)
|
||||
return resolve(null)
|
||||
}
|
||||
var user = this.users.find(u => u.id === payload.userId)
|
||||
resolve(user || null)
|
||||
})
|
||||
@@ -86,7 +89,7 @@ class Auth {
|
||||
var password = req.body.password || ''
|
||||
Logger.debug('Check Auth', username, !!password)
|
||||
|
||||
var user = this.users.find(u => u.id === username)
|
||||
var user = this.users.find(u => u.username === username)
|
||||
|
||||
if (!user) {
|
||||
return res.json({ error: 'User not found' })
|
||||
@@ -114,65 +117,50 @@ class Auth {
|
||||
}
|
||||
}
|
||||
|
||||
async checkAuth(req, res) {
|
||||
var username = req.body.username
|
||||
Logger.debug('Check Auth', username, !!req.body.password)
|
||||
comparePassword(password, user) {
|
||||
if (user.type === 'root' && !password && !user.pash) return true
|
||||
if (!password || !user.pash) return false
|
||||
return bcrypt.compare(password, user.pash)
|
||||
}
|
||||
|
||||
var matchingUser = this.users.find(u => u.username === username)
|
||||
if (!matchingUser) {
|
||||
async userChangePassword(req, res) {
|
||||
var { password, newPassword } = req.body
|
||||
newPassword = newPassword || ''
|
||||
var matchingUser = this.users.find(u => u.id === req.user.id)
|
||||
|
||||
// Only root can have an empty password
|
||||
if (matchingUser.type !== 'root' && !newPassword) {
|
||||
return res.json({
|
||||
error: 'User not found'
|
||||
error: 'Invalid new password - Only root can have an empty password'
|
||||
})
|
||||
}
|
||||
|
||||
var cleanedUser = { ...matchingUser }
|
||||
delete cleanedUser.pash
|
||||
|
||||
// check for empty password (default)
|
||||
if (!req.body.password) {
|
||||
if (!matchingUser.pash) {
|
||||
res.cookie('user', username, { signed: true })
|
||||
return res.json({
|
||||
user: cleanedUser
|
||||
})
|
||||
} else {
|
||||
return res.json({
|
||||
error: 'Invalid Password'
|
||||
})
|
||||
}
|
||||
var compare = await this.comparePassword(password, matchingUser)
|
||||
if (!compare) {
|
||||
return res.json({
|
||||
error: 'Invalid password'
|
||||
})
|
||||
}
|
||||
|
||||
// Set root password first time
|
||||
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
|
||||
console.log('Set root pash')
|
||||
var pw = await this.hashPass(req.body.password)
|
||||
var pw = ''
|
||||
if (newPassword) {
|
||||
pw = await this.hashPass(newPassword)
|
||||
if (!pw) {
|
||||
return res.json({
|
||||
error: 'Hash failed'
|
||||
})
|
||||
}
|
||||
this.users = this.users.map(u => {
|
||||
if (u.username === matchingUser.username) {
|
||||
u.pash = pw
|
||||
}
|
||||
return u
|
||||
})
|
||||
await this.saveAuthDb()
|
||||
return res.json({
|
||||
setroot: true,
|
||||
user: cleanedUser
|
||||
})
|
||||
}
|
||||
|
||||
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
|
||||
if (compare) {
|
||||
res.cookie('user', username, { signed: true })
|
||||
matchingUser.pash = pw
|
||||
var success = await this.db.updateEntity('user', matchingUser)
|
||||
if (success) {
|
||||
res.json({
|
||||
user: cleanedUser
|
||||
success: true
|
||||
})
|
||||
} else {
|
||||
res.json({
|
||||
error: 'Invalid Password'
|
||||
error: 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
const Path = require('path')
|
||||
class Book {
|
||||
constructor(book = null) {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.author = null
|
||||
this.series = null
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.genres = []
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
}
|
||||
}
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.author = book.author
|
||||
this.series = book.series
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.genres = book.genres
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
series: this.series,
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
genres: this.genres
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.author = data.author || null
|
||||
this.series = data.series || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.genres = data.genres || []
|
||||
|
||||
// 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.join('/local', imageFile.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
for (const key in payload) {
|
||||
if (payload[key] === undefined) continue;
|
||||
|
||||
if (key === 'genres') {
|
||||
if (payload['genres'] === null && this.genres !== null) {
|
||||
this.genres = []
|
||||
hasUpdates = true
|
||||
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
|
||||
this.genres = payload['genres']
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
@@ -12,7 +12,7 @@ class BookFinder {
|
||||
async findByISBN(isbn) {
|
||||
var book = await this.openLibrary.isbnLookup(isbn)
|
||||
if (book.errorCode) {
|
||||
console.error('Book not found')
|
||||
Logger.error('Book not found')
|
||||
}
|
||||
return book
|
||||
}
|
||||
@@ -26,7 +26,17 @@ class BookFinder {
|
||||
return title
|
||||
}
|
||||
|
||||
replaceAccentedChars(str) {
|
||||
try {
|
||||
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
} catch (error) {
|
||||
Logger.error('[BookFinder] str normalize error', error)
|
||||
return str
|
||||
}
|
||||
}
|
||||
|
||||
cleanTitleForCompares(title) {
|
||||
if (!title) return ''
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
var stripped = this.stripSubtitle(title)
|
||||
|
||||
@@ -35,71 +45,98 @@ class BookFinder {
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
cleaned = this.replaceAccentedChars(cleaned)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
var cleaned = this.replaceAccentedChars(author)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var searchTitle = this.cleanTitleForCompares(title)
|
||||
var searchAuthor = this.cleanAuthorForCompares(author)
|
||||
return books.map(b => {
|
||||
b.cleanedTitle = this.cleanTitleForCompares(b.title)
|
||||
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
|
||||
if (author) {
|
||||
b.authorDistance = levenshteinDistance(b.author || '', author)
|
||||
}
|
||||
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
|
||||
|
||||
// Total length of search (title or both title & author)
|
||||
b.totalPossibleDistance = b.title.length
|
||||
|
||||
if (b.cleanedTitle.includes(searchTitle) && searchTitle.length > 4) {
|
||||
b.includesSearch = searchTitle
|
||||
} else if (b.title.includes(searchTitle) && searchTitle.length > 4) {
|
||||
b.includesSearch = searchTitle
|
||||
}
|
||||
if (author) {
|
||||
if (!b.author) {
|
||||
b.authorDistance = author.length
|
||||
} else {
|
||||
b.totalPossibleDistance += b.author.length
|
||||
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
|
||||
|
||||
if (author && b.author) b.totalPossibleDistance += b.author.length
|
||||
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
|
||||
var authorDistance = levenshteinDistance(b.author || '', author)
|
||||
|
||||
// Use best distance
|
||||
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
|
||||
|
||||
// Check book author contains searchAuthor
|
||||
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
|
||||
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
|
||||
}
|
||||
}
|
||||
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
|
||||
|
||||
// Check book title contains the searchTitle
|
||||
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
|
||||
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
|
||||
|
||||
return b
|
||||
}).filter(b => {
|
||||
if (b.includesSearch) { // If search was found in result title exactly then skip over leven distance check
|
||||
Logger.debug(`Exact search was found inside title ${b.cleanedTitle}/${b.includesSearch}`)
|
||||
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}"`)
|
||||
} else if (b.titleDistance > maxTitleDistance) {
|
||||
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (author && b.authorDistance > maxAuthorDistance) {
|
||||
Logger.debug(`Filtering out search result "${b.title}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
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}"`)
|
||||
} else if (b.authorDistance > maxAuthorDistance) {
|
||||
Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (b.totalPossibleDistance < 4 && b.totalDistance > 0) return false
|
||||
// If book total search length < 5 and was not exact match, then filter out
|
||||
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var books = await this.libGen.search(title)
|
||||
Logger.info(`LibGen Book Search Results: ${books.length || 0}`)
|
||||
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.info(`Search has ${books.length} matches, but no close title matches`)
|
||||
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.info(`OpenLib Book Search Results: ${books.length || 0}`)
|
||||
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.info(`Search has ${books.length} matches, but no close title matches`)
|
||||
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
|
||||
}
|
||||
return booksFiltered
|
||||
}
|
||||
@@ -108,7 +145,7 @@ class BookFinder {
|
||||
var books = []
|
||||
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
|
||||
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
|
||||
Logger.info(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
Logger.debug(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
|
||||
|
||||
if (provider === 'libgen') {
|
||||
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
@@ -119,18 +156,16 @@ class BookFinder {
|
||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks, olBooks)
|
||||
} else {
|
||||
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var hasCloseMatch = olBooks.find(b => (b.totalDistance < 4 && b.totalPossibleDistance > 4))
|
||||
if (hasCloseMatch) {
|
||||
books = olBooks
|
||||
} else {
|
||||
Logger.info(`Book Search, LibGen has no close matches - get openlib results also`)
|
||||
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
|
||||
if (!hasCloseMatch) {
|
||||
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
|
||||
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
|
||||
books = books.concat(lbBooks)
|
||||
}
|
||||
|
||||
if (!books.length && author) {
|
||||
Logger.info(`Book Search, no matches for title and author.. check title only`)
|
||||
if (!books.length && author && options.fallbackTitleOnly) {
|
||||
Logger.debug(`Book Search, no matches for title and author.. check title only`)
|
||||
return this.search(provider, title, null, options)
|
||||
}
|
||||
}
|
||||
@@ -142,7 +177,8 @@ class BookFinder {
|
||||
|
||||
async findCovers(provider, title, author, options = {}) {
|
||||
var searchResults = await this.search(provider, title, author, options)
|
||||
console.log('Find Covers search results', searchResults)
|
||||
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
|
||||
|
||||
var covers = []
|
||||
searchResults.forEach((result) => {
|
||||
if (result.covers && result.covers.length) {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
const njodb = require("njodb")
|
||||
const jwt = require('jsonwebtoken')
|
||||
const Logger = require('./Logger')
|
||||
const Audiobook = require('./Audiobook')
|
||||
const User = require('./User')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const User = require('./objects/User')
|
||||
|
||||
class Db {
|
||||
constructor(CONFIG_PATH) {
|
||||
@@ -28,6 +27,12 @@ class Db {
|
||||
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'
|
||||
@@ -52,6 +57,7 @@ class Db {
|
||||
pash: '',
|
||||
stream: null,
|
||||
token,
|
||||
isActive: true,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
@@ -98,8 +104,10 @@ class Db {
|
||||
updateAudiobook(audiobook) {
|
||||
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
|
||||
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Audiobook update failed ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -107,8 +115,10 @@ class Db {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,8 +147,10 @@ class Db {
|
||||
this[arrayKey] = this[arrayKey].map(e => {
|
||||
return e.id === entity.id ? entity : e
|
||||
})
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +167,18 @@ class Db {
|
||||
})
|
||||
}
|
||||
|
||||
recreateAudiobookDb() {
|
||||
return this.audiobooksDb.drop().then((results) => {
|
||||
Logger.info(`[DB] Dropped audiobook db`, results)
|
||||
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
|
||||
this.audiobooks = []
|
||||
return true
|
||||
}).catch((error) => {
|
||||
Logger.error(`[DB] Failed to drop audiobook db`, error)
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
getGenres() {
|
||||
var allGenres = []
|
||||
this.db.audiobooks.forEach((audiobook) => {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
|
||||
const workerThreads = require('worker_threads')
|
||||
const Logger = require('./Logger')
|
||||
const Download = require('./objects/Download')
|
||||
const { writeConcatFile } = require('./utils/ffmpegHelpers')
|
||||
const { getFileSize } = require('./utils/fileUtils')
|
||||
|
||||
class DownloadManager {
|
||||
constructor(db, MetadataPath, emitter) {
|
||||
this.db = db
|
||||
this.MetadataPath = MetadataPath
|
||||
this.emitter = emitter
|
||||
|
||||
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
|
||||
|
||||
this.pendingDownloads = []
|
||||
this.downloads = []
|
||||
}
|
||||
|
||||
getDownload(downloadId) {
|
||||
return this.downloads.find(d => d.id === downloadId)
|
||||
}
|
||||
|
||||
async removeOrphanDownloads() {
|
||||
try {
|
||||
var dirs = await fs.readdir(this.downloadDirPath)
|
||||
if (!dirs || !dirs.length) return true
|
||||
|
||||
await Promise.all(dirs.map(async (dirname) => {
|
||||
var fullPath = Path.join(this.downloadDirPath, dirname)
|
||||
Logger.info(`Removing Orphan Download ${dirname}`)
|
||||
return fs.remove(fullPath)
|
||||
}))
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
downloadSocketRequest(socket, payload) {
|
||||
var client = socket.sheepClient
|
||||
var audiobook = this.db.audiobooks.find(a => a.id === payload.audiobookId)
|
||||
var options = {
|
||||
...payload
|
||||
}
|
||||
delete options.audiobookId
|
||||
this.prepareDownload(client, audiobook, options)
|
||||
}
|
||||
|
||||
getBestFileType(tracks) {
|
||||
if (!tracks || !tracks.length) {
|
||||
return null
|
||||
}
|
||||
var firstTrack = tracks[0]
|
||||
return firstTrack.ext.substr(1)
|
||||
}
|
||||
|
||||
async prepareDownload(client, audiobook, options = {}) {
|
||||
var downloadId = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
var dlpath = Path.join(this.downloadDirPath, downloadId)
|
||||
Logger.info(`Start Download for ${audiobook.id} - DownloadId: ${downloadId} - ${dlpath}`)
|
||||
|
||||
await fs.ensureDir(dlpath)
|
||||
|
||||
var downloadType = options.type || 'singleAudio'
|
||||
delete options.type
|
||||
|
||||
var filepath = null
|
||||
var filename = null
|
||||
var fileext = null
|
||||
var audiobookDirname = Path.basename(audiobook.path)
|
||||
|
||||
if (downloadType === 'singleAudio') {
|
||||
var audioFileType = options.audioFileType || this.getBestFileType(audiobook.tracks)
|
||||
delete options.audioFileType
|
||||
filename = audiobookDirname + '.' + audioFileType
|
||||
fileext = '.' + audioFileType
|
||||
filepath = Path.join(dlpath, filename)
|
||||
}
|
||||
|
||||
var downloadData = {
|
||||
id: downloadId,
|
||||
audiobookId: audiobook.id,
|
||||
type: downloadType,
|
||||
options: options,
|
||||
dirpath: dlpath,
|
||||
fullPath: filepath,
|
||||
filename,
|
||||
ext: fileext,
|
||||
userId: (client && client.user) ? client.user.id : null,
|
||||
socket: (client && client.socket) ? client.socket : null
|
||||
}
|
||||
var download = new Download()
|
||||
download.setData(downloadData)
|
||||
|
||||
if (downloadData.socket) {
|
||||
downloadData.socket.emit('download_started', download.toJSON())
|
||||
}
|
||||
|
||||
if (download.type === 'singleAudio') {
|
||||
this.processSingleAudioDownload(audiobook, download)
|
||||
}
|
||||
}
|
||||
|
||||
async processSingleAudioDownload(audiobook, download) {
|
||||
// var ffmpeg = Ffmpeg()
|
||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||
await writeConcatFile(audiobook.tracks, concatFilePath)
|
||||
|
||||
var workerData = {
|
||||
input: concatFilePath,
|
||||
inputFormat: 'concat',
|
||||
inputOption: '-safe 0',
|
||||
options: [
|
||||
'-loglevel warning',
|
||||
'-map 0:a',
|
||||
'-c:a copy'
|
||||
],
|
||||
output: download.fullPath
|
||||
}
|
||||
var worker = new workerThreads.Worker('./server/utils/downloadWorker.js', { workerData })
|
||||
worker.on('message', (message) => {
|
||||
if (message != null && typeof message === 'object') {
|
||||
if (message.type === 'RESULT') {
|
||||
this.sendResult(download, message)
|
||||
}
|
||||
} else {
|
||||
Logger.error('Invalid worker message', message)
|
||||
}
|
||||
})
|
||||
this.pendingDownloads.push({
|
||||
id: download.id,
|
||||
download,
|
||||
worker
|
||||
})
|
||||
}
|
||||
|
||||
async downloadExpired(download) {
|
||||
Logger.info(`[DownloadManager] Download ${download.id} expired`)
|
||||
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_expired', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
}
|
||||
|
||||
async sendResult(download, result) {
|
||||
// Remove pending download
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
|
||||
if (result.isKilled) {
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_killed', download.toJSON())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_failed', download.toJSON())
|
||||
}
|
||||
this.removeDownload(download)
|
||||
return
|
||||
}
|
||||
|
||||
// Remove files.txt if it was used
|
||||
if (download.type === 'singleAudio') {
|
||||
var concatFilePath = Path.join(download.dirpath, 'files.txt')
|
||||
try {
|
||||
await fs.remove(concatFilePath)
|
||||
} catch (error) {
|
||||
Logger.error('[DownloadManager] Failed to remove files.txt')
|
||||
}
|
||||
}
|
||||
|
||||
result.size = await getFileSize(download.fullPath)
|
||||
download.setComplete(result)
|
||||
if (download.socket) {
|
||||
download.socket.emit('download_ready', download.toJSON())
|
||||
}
|
||||
download.setExpirationTimer(this.downloadExpired.bind(this))
|
||||
|
||||
this.downloads.push(download)
|
||||
Logger.info(`[DownloadManager] Download Ready ${download.id}`)
|
||||
}
|
||||
|
||||
async removeDownload(download) {
|
||||
Logger.info('[DownloadManager] Removing download ' + download.id)
|
||||
|
||||
var pendingDl = this.pendingDownloads.find(d => d.id === download.id)
|
||||
|
||||
if (pendingDl) {
|
||||
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
|
||||
Logger.warn(`[DownloadManager] Removing download in progress - stopping worker`)
|
||||
try {
|
||||
pendingDl.worker.postMessage('STOP')
|
||||
} catch (error) {
|
||||
Logger.error('[DownloadManager] Error posting stop message to worker', error)
|
||||
}
|
||||
}
|
||||
|
||||
await fs.remove(download.dirpath).then(() => {
|
||||
Logger.info('[DownloadManager] Deleted download', download.dirpath)
|
||||
}).catch((err) => {
|
||||
Logger.error('[DownloadManager] Failed to delete download', err)
|
||||
})
|
||||
this.downloads = this.downloads.filter(d => d.id !== download.id)
|
||||
}
|
||||
}
|
||||
module.exports = DownloadManager
|
||||
@@ -30,6 +30,12 @@ class HlsController {
|
||||
var streamId = req.params.stream
|
||||
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
|
||||
|
||||
// development test stream - ignore
|
||||
if (streamId === 'test') {
|
||||
Logger.debug('Test Stream Request', streamId, req.headers, fullFilePath)
|
||||
return res.sendFile(fullFilePath)
|
||||
}
|
||||
|
||||
var exists = await fs.pathExists(fullFilePath)
|
||||
if (!exists) {
|
||||
Logger.warn('File path does not exist', fullFilePath)
|
||||
|
||||
@@ -33,6 +33,11 @@ class Logger {
|
||||
console.info(`[${this.timestamp}] INFO:`, ...args)
|
||||
}
|
||||
|
||||
note(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.INFO) return
|
||||
console.log(`[${this.timestamp}] NOTE:`, ...args)
|
||||
}
|
||||
|
||||
warn(...args) {
|
||||
if (this.LogLevel > LOG_LEVEL.WARN) return
|
||||
console.warn(`[${this.timestamp}] WARN:`, ...args)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
const Podcast = require('podcast')
|
||||
const express = require('express')
|
||||
const ip = require('ip')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
// Not functional at the moment - just an idea
|
||||
class RssFeeds {
|
||||
constructor(Port, db) {
|
||||
this.Port = Port
|
||||
this.db = db
|
||||
this.feeds = {}
|
||||
|
||||
this.router = express()
|
||||
this.init()
|
||||
}
|
||||
|
||||
init() {
|
||||
this.router.get('/:id', this.getFeed.bind(this))
|
||||
}
|
||||
|
||||
getFeed(req, res) {
|
||||
var feed = this.feeds[req.params.id]
|
||||
if (!feed) return null
|
||||
var xml = feed.buildXml()
|
||||
res.set('Content-Type', 'text/xml')
|
||||
res.send(xml)
|
||||
}
|
||||
|
||||
openFeed(audiobook) {
|
||||
var serverAddress = 'http://' + ip.address('public', 'ipv4') + ':' + this.Port
|
||||
Logger.info('Open RSS Feed', 'Server address', serverAddress)
|
||||
|
||||
var feedId = (Date.now() + Math.floor(Math.random() * 1000)).toString(36)
|
||||
const feed = new Podcast({
|
||||
title: audiobook.title,
|
||||
description: 'AudioBookshelf RSS Feed',
|
||||
feedUrl: `${serverAddress}/feeds/${feedId}`,
|
||||
imageUrl: `${serverAddress}/Logo.png`,
|
||||
author: 'advplyr',
|
||||
language: 'en'
|
||||
})
|
||||
audiobook.tracks.forEach((track) => {
|
||||
feed.addItem({
|
||||
title: `Track ${track.index}`,
|
||||
description: `AudioBookshelf Audiobook Track #${track.index}`,
|
||||
url: `${serverAddress}/feeds/${feedId}?track=${track.index}`,
|
||||
author: 'advplyr'
|
||||
})
|
||||
})
|
||||
this.feeds[feedId] = feed
|
||||
return feed
|
||||
}
|
||||
}
|
||||
module.exports = RssFeeds
|
||||
@@ -1,8 +1,9 @@
|
||||
const Logger = require('./Logger')
|
||||
const BookFinder = require('./BookFinder')
|
||||
const Audiobook = require('./Audiobook')
|
||||
const Audiobook = require('./objects/Audiobook')
|
||||
const audioFileScanner = require('./utils/audioFileScanner')
|
||||
const { getAllAudiobookFiles } = require('./utils/scandir')
|
||||
const { comparePaths, getIno } = require('./utils/index')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
|
||||
class Scanner {
|
||||
@@ -12,6 +13,8 @@ class Scanner {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
}
|
||||
|
||||
@@ -19,42 +22,204 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobookData.filter(abd => !!abd.ino)
|
||||
}
|
||||
|
||||
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
||||
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
|
||||
var abdFile = audiobookDataAudioFiles[i]
|
||||
var matchingFile = audiobookAudioFiles.find(af => comparePaths(af.path, abdFile.path))
|
||||
if (matchingFile) {
|
||||
if (!matchingFile.ino) {
|
||||
matchingFile.ino = await getIno(matchingFile.fullPath)
|
||||
}
|
||||
abdFile.ino = matchingFile.ino
|
||||
} else {
|
||||
abdFile.ino = await getIno(abdFile.fullPath)
|
||||
if (!abdFile.ino) {
|
||||
Logger.error('[Scanner] Invalid abdFile ino - ignoring abd audio file', abdFile.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
||||
}
|
||||
|
||||
async scan() {
|
||||
// console.log('Start scan audiobooks', this.audiobooks.map(a => a.fullPath).join(', '))
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
|
||||
|
||||
// Set ino for each ab data as a string
|
||||
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
|
||||
var scanResults = {
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
added: 0
|
||||
}
|
||||
|
||||
// Check for removed audiobooks
|
||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
||||
var dataFound = audiobookDataFound.find(abd => abd.ino === this.audiobooks[i].ino)
|
||||
if (!dataFound) {
|
||||
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`)
|
||||
var audiobookJSON = this.audiobooks[i].toJSONMinified()
|
||||
await this.db.removeEntity('audiobook', this.audiobooks[i].id)
|
||||
scanResults.removed++
|
||||
this.emitter('audiobook_removed', audiobookJSON)
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||
var audiobookData = audiobookDataFound[i]
|
||||
if (!audiobookData.parts.length) {
|
||||
Logger.error('No Valid Parts for Audiobook', audiobookData)
|
||||
} else {
|
||||
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath)
|
||||
if (existingAudiobook) {
|
||||
Logger.info('Audiobook already added', audiobookData.title)
|
||||
// Todo: Update Audiobook here
|
||||
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
|
||||
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
|
||||
|
||||
if (existingAudiobook) {
|
||||
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())
|
||||
scanResults.removed++
|
||||
} else {
|
||||
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
|
||||
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
||||
|
||||
// Check for audio files that were removed
|
||||
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
|
||||
}
|
||||
} else {
|
||||
newAudioFiles.push(file)
|
||||
}
|
||||
})
|
||||
if (newAudioFiles.length) {
|
||||
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
||||
// Scan new audio files found
|
||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||
}
|
||||
|
||||
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())
|
||||
} else {
|
||||
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
|
||||
|
||||
if (existingAudiobook.checkUpdateMissingParts()) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
// Syncs path and fullPath
|
||||
if (existingAudiobook.syncPaths(audiobookData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
await this.db.updateAudiobook(existingAudiobook)
|
||||
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
||||
scanResults.updated++
|
||||
}
|
||||
}
|
||||
} // end if update existing
|
||||
} else {
|
||||
if (!audiobookData.audioFiles.length) {
|
||||
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
|
||||
} else {
|
||||
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
|
||||
var audiobook = new Audiobook()
|
||||
audiobook.setData(audiobookData)
|
||||
await audioFileScanner.scanParts(audiobook, audiobookData.parts)
|
||||
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
||||
if (!audiobook.tracks.length) {
|
||||
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title)
|
||||
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
|
||||
} else {
|
||||
Logger.info('Audiobook Scanned', audiobook.title, `(${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
audiobook.checkUpdateMissingParts()
|
||||
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
||||
await this.db.insertAudiobook(audiobook)
|
||||
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
||||
scanResults.added++
|
||||
}
|
||||
}
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
} // end if add new
|
||||
}
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'files',
|
||||
progress: {
|
||||
total: audiobookDataFound.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
})
|
||||
}
|
||||
})
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[SCANNER] Finished ${secondsToTimestamp(scanElapsed)}`)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
return scanResults
|
||||
}
|
||||
|
||||
async fetchMetadata(id, trackIndex = 0) {
|
||||
@@ -70,6 +235,48 @@ class Scanner {
|
||||
return scanResult
|
||||
}
|
||||
|
||||
async scanCovers() {
|
||||
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
|
||||
var found = 0
|
||||
var notFound = 0
|
||||
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
|
||||
var audiobook = audiobooksNeedingCover[i]
|
||||
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}"`)
|
||||
audiobook.book.cover = results[0]
|
||||
await this.db.updateAudiobook(audiobook)
|
||||
found++
|
||||
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||
} else {
|
||||
notFound++
|
||||
}
|
||||
|
||||
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'covers',
|
||||
progress: {
|
||||
total: audiobooksNeedingCover.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
}
|
||||
})
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return {
|
||||
found,
|
||||
notFound
|
||||
}
|
||||
}
|
||||
|
||||
async find(req, res) {
|
||||
var method = req.params.method
|
||||
var query = req.query
|
||||
@@ -87,7 +294,10 @@ class Scanner {
|
||||
|
||||
async findCovers(req, res) {
|
||||
var query = req.query
|
||||
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
|
||||
var options = {
|
||||
fallbackTitleOnly: !!query.fallbackTitleOnly
|
||||
}
|
||||
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null, options)
|
||||
res.json(result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ const express = require('express')
|
||||
const http = require('http')
|
||||
const SocketIO = require('socket.io')
|
||||
const fs = require('fs-extra')
|
||||
const cookieparser = require('cookie-parser')
|
||||
|
||||
const Auth = require('./Auth')
|
||||
const Watcher = require('./Watcher')
|
||||
@@ -12,15 +11,17 @@ const Db = require('./Db')
|
||||
const ApiController = require('./ApiController')
|
||||
const HlsController = require('./HlsController')
|
||||
const StreamManager = require('./StreamManager')
|
||||
const RssFeeds = require('./RssFeeds')
|
||||
const DownloadManager = require('./DownloadManager')
|
||||
const Logger = require('./Logger')
|
||||
|
||||
class Server {
|
||||
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
|
||||
this.Port = PORT
|
||||
this.Host = '0.0.0.0'
|
||||
this.ConfigPath = CONFIG_PATH
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
this.MetadataPath = METADATA_PATH
|
||||
this.ConfigPath = Path.normalize(CONFIG_PATH)
|
||||
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
|
||||
this.MetadataPath = Path.normalize(METADATA_PATH)
|
||||
|
||||
fs.ensureDirSync(CONFIG_PATH)
|
||||
fs.ensureDirSync(METADATA_PATH)
|
||||
@@ -31,7 +32,9 @@ class Server {
|
||||
this.watcher = new Watcher(this.AudiobookPath)
|
||||
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
|
||||
this.streamManager = new StreamManager(this.db, this.MetadataPath)
|
||||
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this))
|
||||
this.rssFeeds = new RssFeeds(this.Port, this.db)
|
||||
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.emitter.bind(this))
|
||||
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this))
|
||||
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
|
||||
|
||||
this.server = null
|
||||
@@ -40,6 +43,7 @@ class Server {
|
||||
this.clients = {}
|
||||
|
||||
this.isScanning = false
|
||||
this.isScanningCovers = false
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
@@ -51,34 +55,43 @@ class Server {
|
||||
}
|
||||
|
||||
emitter(ev, data) {
|
||||
Logger.debug('EMITTER', ev)
|
||||
if (!this.io) {
|
||||
Logger.error('Invalid IO')
|
||||
return
|
||||
}
|
||||
// Logger.debug('EMITTER', ev)
|
||||
this.io.emit(ev, data)
|
||||
}
|
||||
|
||||
async fileAddedUpdated({ path, fullPath }) {
|
||||
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
|
||||
}
|
||||
|
||||
async fileAddedUpdated({ path, fullPath }) { }
|
||||
async fileRemoved({ path, fullPath }) { }
|
||||
|
||||
async scan() {
|
||||
Logger.info('[SERVER] Starting Scan')
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start')
|
||||
await this.scanner.scan()
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan()
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete')
|
||||
Logger.info('[SERVER] Scan complete')
|
||||
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')
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
await this.downloadManager.removeOrphanDownloads()
|
||||
await this.db.init()
|
||||
this.auth.init()
|
||||
|
||||
@@ -101,25 +114,29 @@ class Server {
|
||||
|
||||
this.server = http.createServer(app)
|
||||
|
||||
app.use(cookieparser('secret_family_recipe'))
|
||||
app.use(this.auth.cors)
|
||||
|
||||
// Static path to generated nuxt
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const distPath = Path.join(global.appRoot, '/client/dist')
|
||||
app.use(express.static(distPath))
|
||||
app.use('/local', express.static(this.AudiobookPath))
|
||||
} else {
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
}
|
||||
app.use(express.static(this.AudiobookPath))
|
||||
|
||||
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
|
||||
app.get('/audiobook/: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.get('/', (req, res) => {
|
||||
res.sendFile('/index.html')
|
||||
})
|
||||
// app.use('/hls', this.hlsController.router)
|
||||
app.use('/feeds', this.rssFeeds.router)
|
||||
|
||||
app.post('/login', (req, res) => this.auth.login(req, res))
|
||||
app.post('/logout', this.logout.bind(this))
|
||||
@@ -128,6 +145,21 @@ class Server {
|
||||
res.json({ success: true })
|
||||
})
|
||||
|
||||
|
||||
// Used in development to set-up streams without authentication
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
app.use('/test-hls', this.hlsController.router)
|
||||
app.get('/test-stream/:id', async (req, res) => {
|
||||
var uri = await this.streamManager.openTestStream(this.MetadataPath, req.params.id)
|
||||
res.send(uri)
|
||||
})
|
||||
app.get('/catalog.json', (req, res) => {
|
||||
Logger.error('Catalog request made', req.headers)
|
||||
res.json()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
this.server.listen(this.Port, this.Host, () => {
|
||||
Logger.info(`Running on http://${this.Host}:${this.Port}`)
|
||||
})
|
||||
@@ -151,9 +183,12 @@ class Server {
|
||||
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
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('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
|
||||
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
|
||||
socket.on('test', () => {
|
||||
socket.emit('test_received', socket.id)
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const Stream = require('./Stream')
|
||||
const Stream = require('./objects/Stream')
|
||||
const StreamTest = require('./test/StreamTest')
|
||||
const Logger = require('./Logger')
|
||||
const fs = require('fs-extra')
|
||||
const Path = require('path')
|
||||
@@ -90,7 +91,8 @@ class StreamManager {
|
||||
Logger.info('Close Stream Request', socket.id)
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
Logger.error('No stream for client', client.user.id)
|
||||
Logger.error('No stream for client', (client && client.user) ? client.user.username : 'No Client')
|
||||
client.socket.emit('stream_closed', 'n/a')
|
||||
return
|
||||
}
|
||||
// var streamId = client.stream.id
|
||||
@@ -100,6 +102,23 @@ class StreamManager {
|
||||
this.db.updateUserStream(client.user.id, null)
|
||||
}
|
||||
|
||||
async openTestStream(streamPath, audiobookId) {
|
||||
Logger.info('Open Stream Test Request', audiobookId)
|
||||
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
|
||||
var stream = new StreamTest(streamPath, audiobook)
|
||||
|
||||
stream.on('closed', () => {
|
||||
console.log('Stream closed')
|
||||
})
|
||||
|
||||
var playlistUri = await stream.generatePlaylist()
|
||||
stream.start()
|
||||
|
||||
Logger.info('Stream Playlist', playlistUri)
|
||||
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
|
||||
return playlistUri
|
||||
}
|
||||
|
||||
streamUpdate(socket, { currentTime, streamId }) {
|
||||
var client = socket.sheepClient
|
||||
if (!client || !client.stream) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
var EventEmitter = require('events')
|
||||
var Logger = require('./Logger')
|
||||
var chokidar = require('chokidar')
|
||||
var Watcher = require('watcher')
|
||||
|
||||
class FolderWatcher extends EventEmitter {
|
||||
constructor(audiobookPath) {
|
||||
@@ -12,15 +12,14 @@ class FolderWatcher extends EventEmitter {
|
||||
|
||||
initWatcher() {
|
||||
try {
|
||||
Logger.info('[WATCHER] Initializing..')
|
||||
this.watcher = chokidar.watch(this.AudiobookPath, {
|
||||
ignoreInitial: true,
|
||||
Logger.info('[FolderWatcher] Initializing..')
|
||||
this.watcher = new Watcher(this.AudiobookPath, {
|
||||
ignored: /(^|[\/\\])\../, // ignore dotfiles
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 2500,
|
||||
pollInterval: 500
|
||||
}
|
||||
renameDetection: true,
|
||||
renameTimeout: 2000,
|
||||
recursive: true,
|
||||
ignoreInitial: true,
|
||||
persistent: true
|
||||
})
|
||||
this.watcher
|
||||
.on('add', (path) => {
|
||||
@@ -29,10 +28,12 @@ class FolderWatcher extends EventEmitter {
|
||||
this.onFileUpdated(path)
|
||||
}).on('unlink', path => {
|
||||
this.onFileRemoved(path)
|
||||
}).on('rename', (path, pathNext) => {
|
||||
this.onRename(path, pathNext)
|
||||
}).on('error', (error) => {
|
||||
Logger.error(`Watcher error: ${error}`)
|
||||
Logger.error(`[FolderWatcher] ${error}`)
|
||||
}).on('ready', () => {
|
||||
Logger.info('[WATCHER] Ready')
|
||||
Logger.info('[FolderWatcher] Ready')
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error('Chokidar watcher failed', error)
|
||||
@@ -45,7 +46,7 @@ class FolderWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
onNewFile(path) {
|
||||
Logger.info('FolderWatcher: New File', path)
|
||||
Logger.debug('FolderWatcher: New File', path)
|
||||
this.emit('file_added', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
@@ -53,7 +54,7 @@ class FolderWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
onFileRemoved(path) {
|
||||
Logger.info('FolderWatcher: File Removed', path)
|
||||
Logger.debug('[FolderWatcher] File Removed', path)
|
||||
this.emit('file_removed', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
@@ -61,11 +62,15 @@ class FolderWatcher extends EventEmitter {
|
||||
}
|
||||
|
||||
onFileUpdated(path) {
|
||||
Logger.info('FolderWatcher: Updated File', path)
|
||||
Logger.debug('[FolderWatcher] Updated File', path)
|
||||
this.emit('file_updated', {
|
||||
path: path.replace(this.AudiobookPath, ''),
|
||||
fullPath: path
|
||||
})
|
||||
}
|
||||
|
||||
onRename(pathFrom, pathTo) {
|
||||
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
|
||||
}
|
||||
}
|
||||
module.exports = FolderWatcher
|
||||
@@ -0,0 +1,146 @@
|
||||
class AudioFile {
|
||||
constructor(data) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
this.filename = null
|
||||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
this.trackNumFromMeta = null
|
||||
this.trackNumFromFilename = null
|
||||
|
||||
this.format = null
|
||||
this.duration = null
|
||||
this.size = null
|
||||
this.bitRate = null
|
||||
this.language = null
|
||||
this.codec = null
|
||||
this.timeBase = null
|
||||
this.channels = null
|
||||
this.channelLayout = null
|
||||
|
||||
this.tagAlbum = null
|
||||
this.tagArtist = null
|
||||
this.tagGenre = null
|
||||
this.tagTitle = null
|
||||
this.tagTrack = null
|
||||
|
||||
this.manuallyVerified = false
|
||||
this.invalid = false
|
||||
this.error = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
filename: this.filename,
|
||||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
trackNumFromMeta: this.trackNumFromMeta,
|
||||
trackNumFromFilename: this.trackNumFromFilename,
|
||||
manuallyVerified: !!this.manuallyVerified,
|
||||
invalid: !!this.invalid,
|
||||
error: this.error || null,
|
||||
format: this.format,
|
||||
duration: this.duration,
|
||||
size: this.size,
|
||||
bitRate: this.bitRate,
|
||||
language: this.language,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.index = data.index
|
||||
this.ino = data.ino
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = data.addedAt
|
||||
this.manuallyVerified = !!data.manuallyVerified
|
||||
this.invalid = !!data.invalid
|
||||
this.error = data.error || null
|
||||
|
||||
this.trackNumFromMeta = data.trackNumFromMeta || null
|
||||
this.trackNumFromFilename = data.trackNumFromFilename || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.bitRate = data.bitRate
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.timeBase = data.timeBase
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channelLayout
|
||||
|
||||
this.tagAlbum = data.tagAlbum
|
||||
this.tagArtist = data.tagArtist
|
||||
this.tagGenre = data.tagGenre
|
||||
this.tagTitle = data.tagTitle
|
||||
this.tagTrack = data.tagTrack
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.index = data.index || null
|
||||
this.ino = data.ino || null
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
|
||||
this.trackNumFromMeta = data.trackNumFromMeta || null
|
||||
this.trackNumFromFilename = data.trackNumFromFilename || null
|
||||
|
||||
this.manuallyVerified = !!data.manuallyVerified
|
||||
this.invalid = !!data.invalid
|
||||
this.error = data.error || null
|
||||
|
||||
this.format = data.format
|
||||
this.duration = data.duration
|
||||
this.size = data.size
|
||||
this.bitRate = data.bit_rate
|
||||
this.language = data.language
|
||||
this.codec = data.codec
|
||||
this.timeBase = data.time_base
|
||||
this.channels = data.channels
|
||||
this.channelLayout = data.channel_layout
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
keysToSync.forEach((key) => {
|
||||
if (newFile[key] !== undefined && newFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = newFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioFile
|
||||
@@ -1,8 +1,10 @@
|
||||
var { bytesPretty } = require('./utils/fileUtils')
|
||||
var { bytesPretty } = require('../utils/fileUtils')
|
||||
|
||||
class AudioTrack {
|
||||
constructor(audioTrack = null) {
|
||||
this.index = null
|
||||
this.ino = null
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.ext = null
|
||||
@@ -31,6 +33,8 @@ class AudioTrack {
|
||||
|
||||
construct(audioTrack) {
|
||||
this.index = audioTrack.index
|
||||
this.ino = audioTrack.ino || null
|
||||
|
||||
this.path = audioTrack.path
|
||||
this.fullPath = audioTrack.fullPath
|
||||
this.ext = audioTrack.ext
|
||||
@@ -45,6 +49,12 @@ 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() {
|
||||
@@ -54,6 +64,7 @@ class AudioTrack {
|
||||
toJSON() {
|
||||
return {
|
||||
index: this.index,
|
||||
ino: this.ino,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
ext: this.ext,
|
||||
@@ -65,12 +76,19 @@ class AudioTrack {
|
||||
language: this.language,
|
||||
timeBase: this.timeBase,
|
||||
channels: this.channels,
|
||||
channelLayout: this.channelLayout
|
||||
channelLayout: this.channelLayout,
|
||||
tagAlbum: this.tagAlbum,
|
||||
tagArtist: this.tagArtist,
|
||||
tagGenre: this.tagGenre,
|
||||
tagTitle: this.tagTitle,
|
||||
tagTrack: this.tagTrack
|
||||
}
|
||||
}
|
||||
|
||||
setData(probeData) {
|
||||
this.index = probeData.index
|
||||
this.ino = probeData.ino || null
|
||||
|
||||
this.path = probeData.path
|
||||
this.fullPath = probeData.fullPath
|
||||
this.ext = probeData.ext
|
||||
@@ -92,5 +110,17 @@ class AudioTrack {
|
||||
this.tagTitle = probeData.file_tag_title || null
|
||||
this.tagTrack = probeData.file_tag_track || null
|
||||
}
|
||||
|
||||
syncFile(newFile) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
|
||||
keysToSync.forEach((key) => {
|
||||
if (newFile[key] !== undefined && newFile[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = newFile[key]
|
||||
}
|
||||
})
|
||||
return hasUpdates
|
||||
}
|
||||
}
|
||||
module.exports = AudioTrack
|
||||
@@ -0,0 +1,394 @@
|
||||
const Path = require('path')
|
||||
const { bytesPretty, elapsedPretty } = require('../utils/fileUtils')
|
||||
const { comparePaths, getIno } = require('../utils/index')
|
||||
const Logger = require('../Logger')
|
||||
const Book = require('./Book')
|
||||
const AudioTrack = require('./AudioTrack')
|
||||
const AudioFile = require('./AudioFile')
|
||||
const AudiobookFile = require('./AudiobookFile')
|
||||
|
||||
class Audiobook {
|
||||
constructor(audiobook = null) {
|
||||
this.id = null
|
||||
this.ino = null // Inode
|
||||
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
|
||||
this.addedAt = null
|
||||
this.lastUpdate = null
|
||||
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
|
||||
this.audioFiles = []
|
||||
this.otherFiles = []
|
||||
|
||||
this.tags = []
|
||||
this.book = null
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
}
|
||||
|
||||
construct(audiobook) {
|
||||
this.id = audiobook.id
|
||||
this.ino = audiobook.ino || null
|
||||
|
||||
this.path = audiobook.path
|
||||
this.fullPath = audiobook.fullPath
|
||||
this.addedAt = audiobook.addedAt
|
||||
this.lastUpdate = audiobook.lastUpdate || this.addedAt
|
||||
|
||||
this.tracks = audiobook.tracks.map(track => new AudioTrack(track))
|
||||
this.missingParts = audiobook.missingParts
|
||||
|
||||
this.audioFiles = audiobook.audioFiles.map(file => new AudioFile(file))
|
||||
this.otherFiles = audiobook.otherFiles.map(file => new AudiobookFile(file))
|
||||
|
||||
this.tags = audiobook.tags
|
||||
if (audiobook.book) {
|
||||
this.book = new Book(audiobook.book)
|
||||
}
|
||||
}
|
||||
|
||||
get title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
}
|
||||
|
||||
get cover() {
|
||||
return this.book ? this.book.cover : ''
|
||||
}
|
||||
|
||||
get author() {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
|
||||
get authorLF() {
|
||||
return this.book ? this.book.authorLF : null
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
|
||||
get totalDuration() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.duration)
|
||||
return total
|
||||
}
|
||||
|
||||
get totalSize() {
|
||||
var total = 0
|
||||
this.tracks.forEach((track) => total += track.size)
|
||||
return total
|
||||
}
|
||||
|
||||
get sizePretty() {
|
||||
return bytesPretty(this.totalSize)
|
||||
}
|
||||
|
||||
get durationPretty() {
|
||||
return elapsedPretty(this.totalDuration)
|
||||
}
|
||||
|
||||
get invalidParts() {
|
||||
return (this.audioFiles || []).filter(af => af.invalid).map(af => ({ filename: af.filename, error: af.error || 'Unknown Error' }))
|
||||
}
|
||||
|
||||
bookToJSON() {
|
||||
return this.book ? this.book.toJSON() : null
|
||||
}
|
||||
|
||||
tracksToJSON() {
|
||||
if (!this.tracks || !this.tracks.length) return []
|
||||
return this.tracks.map(t => t.toJSON())
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
toJSONMinified() {
|
||||
return {
|
||||
id: this.id,
|
||||
ino: this.ino,
|
||||
book: this.bookToJSON(),
|
||||
tags: this.tags,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.totalDuration,
|
||||
size: this.totalSize,
|
||||
hasBookMatch: !!this.book,
|
||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numTracks: this.tracks.length
|
||||
}
|
||||
}
|
||||
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
lastUpdate: this.lastUpdate,
|
||||
duration: this.totalDuration,
|
||||
durationPretty: this.durationPretty,
|
||||
size: this.totalSize,
|
||||
sizePretty: this.sizePretty,
|
||||
missingParts: this.missingParts,
|
||||
invalidParts: this.invalidParts,
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON()
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
async checkUpdateInos() {
|
||||
var hasUpdates = false
|
||||
if (!this.ino) {
|
||||
this.ino = await getIno(this.fullPath)
|
||||
hasUpdates = true
|
||||
}
|
||||
for (let i = 0; i < this.audioFiles.length; i++) {
|
||||
var af = this.audioFiles[i]
|
||||
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
|
||||
}
|
||||
}
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
|
||||
this.ino = data.ino || null
|
||||
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
this.lastUpdate = this.addedAt
|
||||
|
||||
if (data.otherFiles) {
|
||||
data.otherFiles.forEach((file) => {
|
||||
this.addOtherFile(file)
|
||||
})
|
||||
}
|
||||
|
||||
this.setBook(data)
|
||||
}
|
||||
|
||||
setBook(data) {
|
||||
this.book = new Book()
|
||||
this.book.setData(data)
|
||||
}
|
||||
|
||||
addTrack(trackData) {
|
||||
var track = new AudioTrack()
|
||||
track.setData(trackData)
|
||||
this.tracks.push(track)
|
||||
return track
|
||||
}
|
||||
|
||||
addAudioFile(audioFileData) {
|
||||
var audioFile = new AudioFile()
|
||||
audioFile.setData(audioFileData)
|
||||
this.audioFiles.push(audioFile)
|
||||
return audioFile
|
||||
}
|
||||
|
||||
addOtherFile(fileData) {
|
||||
var file = new AudiobookFile()
|
||||
file.setData(fileData)
|
||||
this.otherFiles.push(file)
|
||||
return file
|
||||
}
|
||||
|
||||
update(payload) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.tags && payload.tags.join(',') !== this.tags.join(',')) {
|
||||
this.tags = payload.tags
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (payload.book) {
|
||||
if (!this.book) {
|
||||
this.setBook(payload.book)
|
||||
hasUpdates = true
|
||||
} else if (this.book.update(payload.book)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
updateAudioTracks(files) {
|
||||
var index = 1
|
||||
this.audioFiles = files.map((file) => {
|
||||
file.manuallyVerified = true
|
||||
file.invalid = false
|
||||
file.error = null
|
||||
file.index = index++
|
||||
return new AudioFile(file)
|
||||
})
|
||||
this.tracks = []
|
||||
this.missingParts = []
|
||||
this.audioFiles.forEach((file) => {
|
||||
this.addTrack(file)
|
||||
})
|
||||
this.lastUpdate = Date.now()
|
||||
}
|
||||
|
||||
removeAudioFile(audioFile) {
|
||||
this.tracks = this.tracks.filter(t => t.ino !== audioFile.ino)
|
||||
this.audioFiles = this.audioFiles.filter(f => f.ino !== audioFile.ino)
|
||||
}
|
||||
|
||||
checkUpdateMissingParts() {
|
||||
var currMissingParts = (this.missingParts || []).join(',') || ''
|
||||
|
||||
var current_index = 1
|
||||
var missingParts = []
|
||||
for (let i = 0; i < this.tracks.length; i++) {
|
||||
var _track = this.tracks[i]
|
||||
if (_track.index > current_index) {
|
||||
var num_parts_missing = _track.index - current_index
|
||||
for (let x = 0; x < num_parts_missing; x++) {
|
||||
missingParts.push(current_index + x)
|
||||
}
|
||||
}
|
||||
current_index = _track.index + 1
|
||||
}
|
||||
|
||||
this.missingParts = missingParts
|
||||
|
||||
var newMissingParts = (this.missingParts || []).join(',') || ''
|
||||
var wasUpdated = newMissingParts !== currMissingParts
|
||||
if (wasUpdated && this.missingParts.length) {
|
||||
Logger.info(`[Audiobook] "${this.title}" has ${missingParts.length} missing parts`)
|
||||
}
|
||||
|
||||
return wasUpdated
|
||||
}
|
||||
|
||||
// On scan check other files found with other files saved
|
||||
syncOtherFiles(newOtherFiles) {
|
||||
var currOtherFileNum = this.otherFiles.length
|
||||
|
||||
var newOtherFilePaths = newOtherFiles.map(f => f.path)
|
||||
this.otherFiles = this.otherFiles.filter(f => newOtherFilePaths.includes(f.path))
|
||||
|
||||
newOtherFiles.forEach((file) => {
|
||||
var existingOtherFile = this.otherFiles.find(f => f.path === file.path)
|
||||
if (!existingOtherFile) {
|
||||
Logger.debug(`[Audiobook] New other file found on sync ${file.filename}/${file.filetype} | "${this.title}"`)
|
||||
this.addOtherFile(file)
|
||||
}
|
||||
})
|
||||
|
||||
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')
|
||||
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)))
|
||||
if (!coverStillExists) {
|
||||
Logger.info(`[Audiobook] Local cover was removed | "${this.title}"`)
|
||||
this.book.cover = null
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// 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}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncAudioFile(audioFile, fileScanData) {
|
||||
var hasUpdates = audioFile.syncFile(fileScanData)
|
||||
var track = this.tracks.find(t => t.ino === audioFile.ino)
|
||||
if (track && track.syncFile(fileScanData)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncPaths(audiobookData) {
|
||||
var hasUpdates = false
|
||||
var keysToSync = ['path', 'fullPath']
|
||||
keysToSync.forEach((key) => {
|
||||
if (audiobookData[key] !== undefined && audiobookData[key] !== this[key]) {
|
||||
hasUpdates = true
|
||||
this[key] = audiobookData[key]
|
||||
}
|
||||
})
|
||||
if (hasUpdates) {
|
||||
this.book.syncPathsUpdated(audiobookData)
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
isSearchMatch(search) {
|
||||
return this.book.isSearchMatch(search.toLowerCase().trim())
|
||||
}
|
||||
|
||||
getAudioFileByIno(ino) {
|
||||
return this.audioFiles.find(af => af.ino === ino)
|
||||
}
|
||||
}
|
||||
module.exports = Audiobook
|
||||
@@ -0,0 +1,48 @@
|
||||
class AudiobookFile {
|
||||
constructor(data) {
|
||||
this.ino = null
|
||||
this.filetype = null
|
||||
this.filename = null
|
||||
this.ext = null
|
||||
this.path = null
|
||||
this.fullPath = null
|
||||
this.addedAt = null
|
||||
|
||||
if (data) {
|
||||
this.construct(data)
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
ino: this.ino || null,
|
||||
filetype: this.filetype,
|
||||
filename: this.filename,
|
||||
ext: this.ext,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt
|
||||
}
|
||||
}
|
||||
|
||||
construct(data) {
|
||||
this.ino = data.ino || null
|
||||
this.filetype = data.filetype
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = data.addedAt
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.ino = data.ino || null
|
||||
this.filetype = data.filetype
|
||||
this.filename = data.filename
|
||||
this.ext = data.ext
|
||||
this.path = data.path
|
||||
this.fullPath = data.fullPath
|
||||
this.addedAt = Date.now()
|
||||
}
|
||||
}
|
||||
module.exports = AudiobookFile
|
||||
@@ -0,0 +1,157 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('../utils/parseAuthors')
|
||||
|
||||
class Book {
|
||||
constructor(book = null) {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.author = null
|
||||
this.authorFL = null
|
||||
this.authorLF = null
|
||||
this.series = null
|
||||
this.volumeNumber = null
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
this.cover = null
|
||||
this.genres = []
|
||||
|
||||
if (book) {
|
||||
this.construct(book)
|
||||
}
|
||||
}
|
||||
|
||||
get _title() { return this.title || '' }
|
||||
get _author() { return this.author || '' }
|
||||
get _series() { return this.series || '' }
|
||||
|
||||
construct(book) {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.author = book.author
|
||||
this.authorFL = book.authorFL || null
|
||||
this.authorLF = book.authorLF || null
|
||||
this.series = book.series
|
||||
this.volumeNumber = book.volumeNumber || null
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
this.cover = book.cover
|
||||
this.genres = book.genres
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
authorFL: this.authorFL,
|
||||
authorLF: this.authorLF,
|
||||
series: this.series,
|
||||
volumeNumber: this.volumeNumber,
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
cover: this.cover,
|
||||
genres: this.genres
|
||||
}
|
||||
}
|
||||
|
||||
setParseAuthor(author) {
|
||||
if (!author) {
|
||||
var hasUpdated = this.authorFL || this.authorLF
|
||||
this.authorFL = null
|
||||
this.authorLF = null
|
||||
return hasUpdated
|
||||
}
|
||||
try {
|
||||
var { authorLF, authorFL } = parseAuthors(author)
|
||||
var hasUpdated = authorLF !== this.authorLF || authorFL !== this.authorFL
|
||||
this.authorFL = authorFL || null
|
||||
this.authorLF = authorLF || null
|
||||
return hasUpdated
|
||||
} catch (err) {
|
||||
Logger.error('[Book] Parse authors failed', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
setData(data) {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.author = data.author || null
|
||||
this.series = data.series || null
|
||||
this.volumeNumber = data.volumeNumber || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
this.genres = data.genres || []
|
||||
|
||||
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) {
|
||||
var hasUpdates = false
|
||||
|
||||
if (payload.cover) {
|
||||
// If updating to local cover then normalize path
|
||||
if (!payload.cover.startsWith('http:') && !payload.cover.startsWith('https:')) {
|
||||
payload.cover = Path.normalize(payload.cover)
|
||||
}
|
||||
}
|
||||
|
||||
for (const key in payload) {
|
||||
if (payload[key] === undefined) continue;
|
||||
|
||||
if (key === 'genres') {
|
||||
if (payload['genres'] === null && this.genres !== null) {
|
||||
this.genres = []
|
||||
hasUpdates = true
|
||||
} else if (payload['genres'].join(',') !== this.genres.join(',')) {
|
||||
this.genres = payload['genres']
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (key === 'author') {
|
||||
if (this.author !== payload.author) {
|
||||
this.author = payload.author || null
|
||||
hasUpdates = true
|
||||
}
|
||||
if (this.setParseAuthor(this.author)) {
|
||||
hasUpdates = true
|
||||
}
|
||||
} else if (this[key] !== undefined && payload[key] !== this[key]) {
|
||||
this[key] = payload[key]
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
// 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) {
|
||||
var keysToSync = ['author', 'title', 'series', 'publishYear']
|
||||
var syncPayload = {}
|
||||
keysToSync.forEach((key) => {
|
||||
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
|
||||
})
|
||||
if (!Object.keys(syncPayload).length) return false
|
||||
return this.update(syncPayload)
|
||||
}
|
||||
|
||||
isSearchMatch(search) {
|
||||
return this._title.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
|
||||
}
|
||||
}
|
||||
module.exports = Book
|
||||
@@ -0,0 +1,107 @@
|
||||
const DEFAULT_EXPIRATION = 1000 * 60 * 10 // 10 minutes
|
||||
|
||||
class Download {
|
||||
constructor(download) {
|
||||
this.id = null
|
||||
this.audiobookId = null
|
||||
this.type = null
|
||||
this.options = {}
|
||||
|
||||
this.dirpath = null
|
||||
this.fullPath = null
|
||||
this.ext = null
|
||||
this.filename = null
|
||||
this.size = 0
|
||||
|
||||
this.userId = null
|
||||
this.socket = null // Socket to notify when complete
|
||||
this.isReady = false
|
||||
|
||||
this.startedAt = null
|
||||
this.finishedAt = null
|
||||
this.expiresAt = null
|
||||
|
||||
this.expirationTimeMs = 0
|
||||
|
||||
if (download) {
|
||||
this.construct(download)
|
||||
}
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
if (this.ext === '.mp3' || this.ext === '.m4b' || this.ext === '.m4a') {
|
||||
return 'audio/mpeg'
|
||||
} else if (this.ext === '.mp4') {
|
||||
return 'audio/mp4'
|
||||
} else if (this.ext === '.ogg') {
|
||||
return 'audio/ogg'
|
||||
} else if (this.ext === '.aac' || this.ext === '.m4p') {
|
||||
return 'audio/aac'
|
||||
}
|
||||
return 'audio/mpeg'
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
audiobookId: this.audiobookId,
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
dirpath: this.dirpath,
|
||||
fullPath: this.fullPath,
|
||||
ext: this.ext,
|
||||
filename: this.filename,
|
||||
size: this.size,
|
||||
userId: this.userId,
|
||||
isReady: this.isReady,
|
||||
startedAt: this.startedAt,
|
||||
finishedAt: this.finishedAt,
|
||||
expirationSeconds: this.expirationSeconds
|
||||
}
|
||||
}
|
||||
|
||||
construct(download) {
|
||||
this.id = download.id
|
||||
this.audiobookId = download.audiobookId
|
||||
this.type = download.type
|
||||
this.options = { ...download.options }
|
||||
|
||||
this.dirpath = download.dirpath
|
||||
this.fullPath = download.fullPath
|
||||
this.ext = download.ext
|
||||
this.filename = download.filename
|
||||
this.size = download.size || 0
|
||||
|
||||
this.userId = download.userId
|
||||
this.socket = download.socket || null
|
||||
this.isReady = !!download.isReady
|
||||
|
||||
this.startedAt = download.startedAt
|
||||
this.finishedAt = download.finishedAt || null
|
||||
|
||||
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
|
||||
this.expiresAt = download.expiresAt || null
|
||||
}
|
||||
|
||||
setData(downloadData) {
|
||||
downloadData.startedAt = Date.now()
|
||||
downloadData.isProcessing = true
|
||||
this.construct(downloadData)
|
||||
}
|
||||
|
||||
setComplete(fileSize) {
|
||||
this.finishedAt = Date.now()
|
||||
this.size = fileSize
|
||||
this.isReady = true
|
||||
this.expiresAt = this.finishedAt + this.expirationTimeMs
|
||||
}
|
||||
|
||||
setExpirationTimer(callback) {
|
||||
setTimeout(() => {
|
||||
if (callback) {
|
||||
callback(this)
|
||||
}
|
||||
}, this.expirationTimeMs)
|
||||
}
|
||||
}
|
||||
module.exports = Download
|
||||
@@ -2,9 +2,10 @@ const Ffmpeg = require('fluent-ffmpeg')
|
||||
const EventEmitter = require('events')
|
||||
const Path = require('path')
|
||||
const fs = require('fs-extra')
|
||||
const Logger = require('./Logger')
|
||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
|
||||
const Logger = require('../Logger')
|
||||
const { secondsToTimestamp } = require('../utils/fileUtils')
|
||||
const { writeConcatFile } = require('../utils/ffmpegHelpers')
|
||||
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
|
||||
|
||||
class Stream extends EventEmitter {
|
||||
constructor(streamPath, client, audiobook) {
|
||||
@@ -19,7 +20,7 @@ class Stream extends EventEmitter {
|
||||
this.streamPath = Path.join(streamPath, this.id)
|
||||
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
|
||||
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
|
||||
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
|
||||
this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')
|
||||
this.startTime = 0
|
||||
|
||||
this.ffmpeg = null
|
||||
@@ -129,7 +130,6 @@ class Stream extends EventEmitter {
|
||||
async generatePlaylist() {
|
||||
fs.ensureDirSync(this.streamPath)
|
||||
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
|
||||
console.log('Playlist generated')
|
||||
return this.clientPlaylistUri
|
||||
}
|
||||
|
||||
@@ -212,29 +212,12 @@ class Stream extends EventEmitter {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
|
||||
}
|
||||
|
||||
async start() {
|
||||
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
|
||||
|
||||
this.ffmpeg = Ffmpeg()
|
||||
var currTrackEnd = 0
|
||||
var startingTrack = this.tracks.find(t => {
|
||||
currTrackEnd += t.duration
|
||||
return this.startTime < currTrackEnd
|
||||
})
|
||||
var trackStartTime = currTrackEnd - startingTrack.duration
|
||||
|
||||
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
|
||||
var trackPaths = tracksToInclude.map(t => {
|
||||
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||
return line
|
||||
})
|
||||
var inputstr = trackPaths.join('\n\n')
|
||||
await fs.writeFile(this.concatFilesPath, inputstr)
|
||||
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
|
||||
|
||||
this.ffmpeg.addInput(this.concatFilesPath)
|
||||
this.ffmpeg.inputFormat('concat')
|
||||
@@ -267,7 +250,7 @@ class Stream extends EventEmitter {
|
||||
])
|
||||
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
|
||||
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
|
||||
this.ffmpeg.output(this.fakePlaylistPath)
|
||||
this.ffmpeg.output(this.finalPlaylistPath)
|
||||
|
||||
this.ffmpeg.on('start', (command) => {
|
||||
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
|
||||
@@ -6,14 +6,26 @@ class User {
|
||||
this.type = null
|
||||
this.stream = null
|
||||
this.token = null
|
||||
this.isActive = true
|
||||
this.createdAt = null
|
||||
this.audiobooks = null
|
||||
this.settings = {}
|
||||
|
||||
if (user) {
|
||||
this.construct(user)
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultUserSettings() {
|
||||
return {
|
||||
orderBy: 'book.title',
|
||||
orderDesc: false,
|
||||
filterBy: 'all',
|
||||
playbackRate: 1,
|
||||
bookshelfCoverSize: 120
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
id: this.id,
|
||||
@@ -23,7 +35,9 @@ class User {
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooks,
|
||||
createdAt: this.createdAt
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +49,9 @@ class User {
|
||||
stream: this.stream,
|
||||
token: this.token,
|
||||
audiobooks: this.audiobooks,
|
||||
createdAt: this.createdAt
|
||||
isActive: this.isActive,
|
||||
createdAt: this.createdAt,
|
||||
settings: this.settings
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +60,12 @@ class User {
|
||||
this.username = user.username
|
||||
this.pash = user.pash
|
||||
this.type = user.type
|
||||
this.stream = user.stream
|
||||
this.stream = user.stream || null
|
||||
this.token = user.token
|
||||
this.audiobooks = user.audiobooks || null
|
||||
this.createdAt = user.createdAt
|
||||
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
|
||||
this.createdAt = user.createdAt || Date.now()
|
||||
this.settings = user.settings || this.getDefaultUserSettings()
|
||||
}
|
||||
|
||||
updateAudiobookProgress(stream) {
|
||||
@@ -64,6 +82,32 @@ class User {
|
||||
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
|
||||
}
|
||||
|
||||
// Returns Boolean If update was made
|
||||
updateSettings(settings) {
|
||||
if (!this.settings) {
|
||||
this.settings = { ...settings }
|
||||
return true
|
||||
}
|
||||
var madeUpdates = false
|
||||
|
||||
for (const key in this.settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] !== settings[key]) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new settings update has keys not currently in user settings
|
||||
for (const key in settings) {
|
||||
if (settings[key] !== undefined && this.settings[key] === undefined) {
|
||||
this.settings[key] = settings[key]
|
||||
madeUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
return madeUpdates
|
||||
}
|
||||
|
||||
resetAudiobookProgress(audiobookId) {
|
||||
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
|
||||
return false
|
||||
@@ -26,6 +26,12 @@ class OpenLibrary {
|
||||
|
||||
async getWorksData(worksKey) {
|
||||
var worksData = await this.get(`${worksKey}.json`)
|
||||
if (!worksData) {
|
||||
return {
|
||||
errorMsg: 'Works Data Request failed',
|
||||
errorCode: 500
|
||||
}
|
||||
}
|
||||
if (!worksData.covers) worksData.covers = []
|
||||
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
|
||||
var description = null
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const Path = require('path')
|
||||
const Logger = require('../Logger')
|
||||
var prober = require('./prober')
|
||||
|
||||
const prober = require('./prober')
|
||||
|
||||
function getDefaultAudioStream(audioStreams) {
|
||||
if (audioStreams.length === 1) return audioStreams[0]
|
||||
@@ -76,60 +75,61 @@ function getTrackNumberFromFilename(filename) {
|
||||
return number
|
||||
}
|
||||
|
||||
async function scanParts(audiobook, parts) {
|
||||
if (!parts || !parts.length) {
|
||||
Logger.error('Scan Parts', audiobook.title, 'No Parts', parts)
|
||||
async function scanAudioFiles(audiobook, newAudioFiles) {
|
||||
if (!newAudioFiles || !newAudioFiles.length) {
|
||||
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
|
||||
return
|
||||
}
|
||||
var tracks = []
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
var fullPath = Path.join(audiobook.fullPath, parts[i])
|
||||
for (let i = 0; i < newAudioFiles.length; i++) {
|
||||
var audioFile = newAudioFiles[i]
|
||||
|
||||
var scanData = await scan(fullPath)
|
||||
var scanData = await scan(audioFile.fullPath)
|
||||
if (!scanData || scanData.error) {
|
||||
Logger.error('Scan failed for', parts[i])
|
||||
audiobook.invalidParts.push(parts[i])
|
||||
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
|
||||
// audiobook.invalidAudioFiles.push(parts[i])
|
||||
continue;
|
||||
}
|
||||
|
||||
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
|
||||
var trackNumFromFilename = getTrackNumberFromFilename(parts[i])
|
||||
var trackNumFromFilename = getTrackNumberFromFilename(audioFile.filename)
|
||||
|
||||
var audioFileObj = {
|
||||
path: Path.join(audiobook.path, parts[i]),
|
||||
ext: Path.extname(parts[i]),
|
||||
filename: parts[i],
|
||||
fullPath: fullPath,
|
||||
ino: audioFile.ino,
|
||||
filename: audioFile.filename,
|
||||
path: audioFile.path,
|
||||
fullPath: audioFile.fullPath,
|
||||
ext: audioFile.ext,
|
||||
...scanData,
|
||||
trackNumFromMeta,
|
||||
trackNumFromFilename
|
||||
}
|
||||
audiobook.audioFiles.push(audioFileObj)
|
||||
var audioFile = audiobook.addAudioFile(audioFileObj)
|
||||
|
||||
var trackNumber = 1
|
||||
if (parts.length > 1) {
|
||||
if (newAudioFiles.length > 1) {
|
||||
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
|
||||
if (trackNumber === null) {
|
||||
Logger.error('Invalid track number for', parts[i])
|
||||
audioFileObj.invalid = true
|
||||
audioFileObj.error = 'Failed to get track number'
|
||||
Logger.error('[AudioFileScanner] Invalid track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Failed to get track number'
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (tracks.find(t => t.index === trackNumber)) {
|
||||
Logger.error('Duplicate track number for', parts[i])
|
||||
audioFileObj.invalid = true
|
||||
audioFileObj.error = 'Duplicate track number'
|
||||
Logger.error('[AudioFileScanner] Duplicate track number for', audioFile.filename)
|
||||
audioFile.invalid = true
|
||||
audioFile.error = 'Duplicate track number'
|
||||
continue;
|
||||
}
|
||||
|
||||
audioFileObj.index = trackNumber
|
||||
tracks.push(audioFileObj)
|
||||
audioFile.index = trackNumber
|
||||
tracks.push(audioFile)
|
||||
}
|
||||
|
||||
if (!tracks.length) {
|
||||
Logger.warn('No Tracks for audiobook', audiobook.id)
|
||||
Logger.warn('[AudioFileScanner] No Tracks for audiobook', audiobook.id)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -148,26 +148,12 @@ async function scanParts(audiobook, parts) {
|
||||
})
|
||||
}
|
||||
|
||||
var parts_copy = tracks.map(p => ({ ...p }))
|
||||
var current_index = 1
|
||||
|
||||
for (let i = 0; i < parts_copy.length; i++) {
|
||||
var cleaned_part = parts_copy[i]
|
||||
if (cleaned_part.index > current_index) {
|
||||
var num_parts_missing = cleaned_part.index - current_index
|
||||
for (let x = 0; x < num_parts_missing; x++) {
|
||||
audiobook.missingParts.push(current_index + x)
|
||||
}
|
||||
}
|
||||
current_index = cleaned_part.index + 1
|
||||
}
|
||||
|
||||
if (audiobook.missingParts.length) {
|
||||
Logger.info('Audiobook', audiobook.title, 'Has missing parts', audiobook.missingParts)
|
||||
}
|
||||
|
||||
var hasTracksAlready = audiobook.tracks.length
|
||||
tracks.forEach((track) => {
|
||||
audiobook.addTrack(track)
|
||||
})
|
||||
if (hasTracksAlready) {
|
||||
audiobook.tracks.sort((a, b) => a.index - b.index)
|
||||
}
|
||||
}
|
||||
module.exports.scanParts = scanParts
|
||||
module.exports.scanAudioFiles = scanAudioFiles
|
||||
@@ -0,0 +1,68 @@
|
||||
const Ffmpeg = require('fluent-ffmpeg')
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
Ffmpeg.setFfmpegPath(process.env.FFMPEG_PATH)
|
||||
}
|
||||
|
||||
const { parentPort, workerData } = require("worker_threads")
|
||||
const Logger = require('../Logger')
|
||||
|
||||
Logger.info('[DownloadWorker] Starting Worker...')
|
||||
|
||||
|
||||
const ffmpegCommand = Ffmpeg()
|
||||
const startTime = Date.now()
|
||||
|
||||
ffmpegCommand.input(workerData.input)
|
||||
if (workerData.inputFormat) ffmpegCommand.inputFormat(workerData.inputFormat)
|
||||
if (workerData.inputOption) ffmpegCommand.inputOption(workerData.inputOption)
|
||||
if (workerData.options) ffmpegCommand.addOption(workerData.options)
|
||||
ffmpegCommand.output(workerData.output)
|
||||
|
||||
var isKilled = false
|
||||
|
||||
async function runFfmpeg() {
|
||||
var success = await new Promise((resolve) => {
|
||||
ffmpegCommand.on('start', (command) => {
|
||||
Logger.info('[DownloadWorker] FFMPEG concat started with command: ' + command)
|
||||
})
|
||||
|
||||
ffmpegCommand.on('stderr', (stdErrline) => {
|
||||
Logger.info(stdErrline)
|
||||
})
|
||||
|
||||
ffmpegCommand.on('error', (err, stdout, stderr) => {
|
||||
if (err.message && err.message.includes('SIGKILL')) {
|
||||
// This is an intentional SIGKILL
|
||||
Logger.info('[DownloadWorker] User Killed singleAudio')
|
||||
} else {
|
||||
Logger.error('[DownloadWorker] Ffmpeg Err', err.message)
|
||||
}
|
||||
resolve(false)
|
||||
})
|
||||
|
||||
ffmpegCommand.on('end', (stdout, stderr) => {
|
||||
Logger.info('[DownloadWorker] singleAudio ended')
|
||||
resolve(true)
|
||||
})
|
||||
ffmpegCommand.run()
|
||||
})
|
||||
|
||||
var resultMessage = {
|
||||
type: 'RESULT',
|
||||
isKilled,
|
||||
elapsed: Date.now() - startTime,
|
||||
success
|
||||
}
|
||||
parentPort.postMessage(resultMessage)
|
||||
}
|
||||
|
||||
parentPort.on('message', (message) => {
|
||||
if (message === 'STOP') {
|
||||
Logger.info('[DownloadWorker] Requested a hard stop')
|
||||
isKilled = true
|
||||
ffmpegCommand.kill()
|
||||
}
|
||||
})
|
||||
|
||||
runFfmpeg()
|
||||
@@ -0,0 +1,37 @@
|
||||
const fs = require('fs-extra')
|
||||
|
||||
function escapeSingleQuotes(path) {
|
||||
// return path.replace(/'/g, '\'\\\'\'')
|
||||
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
|
||||
}
|
||||
|
||||
// Returns first track start time
|
||||
// startTime is for streams starting an encode part-way through an audiobook
|
||||
async function writeConcatFile(tracks, outputPath, startTime = 0) {
|
||||
var trackToStartWithIndex = 0
|
||||
var firstTrackStartTime = 0
|
||||
|
||||
// Find first track greater than startTime
|
||||
if (startTime > 0) {
|
||||
var currTrackEnd = 0
|
||||
var startingTrack = tracks.find(t => {
|
||||
currTrackEnd += t.duration
|
||||
return startTime < currTrackEnd
|
||||
})
|
||||
if (startingTrack) {
|
||||
firstTrackStartTime = currTrackEnd - startingTrack.duration
|
||||
trackToStartWithIndex = startingTrack.index
|
||||
}
|
||||
}
|
||||
|
||||
var tracksToInclude = tracks.filter(t => t.index >= trackToStartWithIndex)
|
||||
var trackPaths = tracksToInclude.map(t => {
|
||||
var line = 'file ' + escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
|
||||
return line
|
||||
})
|
||||
var inputstr = trackPaths.join('\n\n')
|
||||
await fs.writeFile(outputPath, inputstr)
|
||||
|
||||
return firstTrackStartTime
|
||||
}
|
||||
module.exports.writeConcatFile = writeConcatFile
|
||||
@@ -17,6 +17,13 @@ async function getFileStat(path) {
|
||||
}
|
||||
module.exports.getFileStat = getFileStat
|
||||
|
||||
async function getFileSize(path) {
|
||||
var stat = await getFileStat(path)
|
||||
if (!stat) return 0
|
||||
return stat.size || 0
|
||||
}
|
||||
module.exports.getFileSize = getFileSize
|
||||
|
||||
function bytesPretty(bytes, decimals = 0) {
|
||||
if (bytes === 0) {
|
||||
return '0 Bytes'
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
const Path = require('path')
|
||||
const fs = require('fs')
|
||||
const Logger = require('../Logger')
|
||||
|
||||
const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
||||
if (!caseSensitive) {
|
||||
str1 = str1.toLowerCase()
|
||||
@@ -23,4 +27,36 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
|
||||
}
|
||||
return track[str2.length][str1.length];
|
||||
}
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
module.exports.levenshteinDistance = levenshteinDistance
|
||||
|
||||
const cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
|
||||
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
|
||||
const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
|
||||
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
cleaned += cleanChar(str[i])
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
module.exports.cleanString = cleanString
|
||||
|
||||
module.exports.isObject = (val) => {
|
||||
return val !== null && typeof val === 'object'
|
||||
}
|
||||
|
||||
module.exports.comparePaths = (path1, path2) => {
|
||||
return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)
|
||||
}
|
||||
|
||||
module.exports.getIno = (path) => {
|
||||
return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
|
||||
Logger.error('[Utils] Failed to get ino for path', path, error)
|
||||
return null
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const parseFullName = require('./parseFullName')
|
||||
|
||||
function parseName(name) {
|
||||
var parts = parseFullName(name)
|
||||
var firstName = parts.first
|
||||
if (firstName && parts.middle) firstName += ' ' + parts.middle
|
||||
|
||||
return {
|
||||
first_name: firstName,
|
||||
last_name: parts.last
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this name segment is of the format "Last, First" or "First Last"
|
||||
// return true is "Last, First"
|
||||
function checkIsALastName(name) {
|
||||
if (!name.includes(' ')) return true // No spaces must be a Last name
|
||||
|
||||
var parsed = parseFullName(name)
|
||||
if (!parsed.first) return true // had spaces but not a first name i.e. "von Mises", must be last name only
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
module.exports = (author) => {
|
||||
if (!author) return null
|
||||
var splitByComma = author.split(', ')
|
||||
|
||||
var authors = []
|
||||
|
||||
// 1 author FIRST LAST
|
||||
if (splitByComma.length === 1) {
|
||||
authors.push(parseName(author))
|
||||
} else {
|
||||
var firstChunkIsALastName = checkIsALastName(splitByComma[0])
|
||||
var isEvenNum = splitByComma.length % 2 === 0
|
||||
|
||||
if (!isEvenNum && firstChunkIsALastName) {
|
||||
// console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it', splitByComma[splitByComma.length - 1])
|
||||
splitByComma = splitByComma.slice(0, splitByComma.length - 1)
|
||||
}
|
||||
|
||||
if (firstChunkIsALastName) {
|
||||
var numAuthors = splitByComma.length / 2
|
||||
for (let i = 0; i < numAuthors; i++) {
|
||||
var last = splitByComma.shift()
|
||||
var first = splitByComma.shift()
|
||||
authors.push({
|
||||
first_name: first,
|
||||
last_name: last
|
||||
})
|
||||
}
|
||||
} else {
|
||||
splitByComma.forEach((segment) => {
|
||||
authors.push(parseName(segment))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var firstLast = authors.length ? authors.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name).join(', ') : ''
|
||||
var lastFirst = authors.length ? authors.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : ''
|
||||
return {
|
||||
authorFL: firstLast,
|
||||
authorLF: lastFirst,
|
||||
authorsParsed: authors
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
|
||||
|
||||
// https://github.com/RateGravity/parse-full-name/blob/master/index.js
|
||||
module.exports = (nameToParse, partToReturn, fixCase, stopOnError, useLongLists) => {
|
||||
|
||||
var i, j, k, l, m, n, part, comma, titleList, suffixList, prefixList, regex,
|
||||
partToCheck, partFound, partsFoundCount, firstComma, remainingCommas,
|
||||
nameParts = [], nameCommas = [null], partsFound = [],
|
||||
conjunctionList = ['&', 'and', 'et', 'e', 'of', 'the', 'und', 'y'],
|
||||
parsedName = {
|
||||
title: '', first: '', middle: '', last: '', nick: '', suffix: '', error: []
|
||||
};
|
||||
|
||||
// Validate inputs, or set to defaults
|
||||
partToReturn = partToReturn && ['title', 'first', 'middle', 'last', 'nick',
|
||||
'suffix', 'error'].indexOf(partToReturn.toLowerCase()) > -1 ?
|
||||
partToReturn.toLowerCase() : 'all';
|
||||
// 'all' = return object with all parts, others return single part
|
||||
if (fixCase === false) fixCase = 0;
|
||||
if (fixCase === true) fixCase = 1;
|
||||
fixCase = fixCase !== 'undefined' && (fixCase === 0 || fixCase === 1) ?
|
||||
fixCase : -1; // -1 = fix case only if input is all upper or lowercase
|
||||
if (stopOnError === true) stopOnError = 1;
|
||||
stopOnError = stopOnError && stopOnError === 1 ? 1 : 0;
|
||||
// false = output warnings on parse error, but don't stop
|
||||
if (useLongLists === true) useLongLists = 1;
|
||||
useLongLists = useLongLists && useLongLists === 1 ? 1 : 0; // 0 = short lists
|
||||
|
||||
// If stopOnError = 1, throw error, otherwise return error messages in array
|
||||
function handleError(errorMessage) {
|
||||
if (stopOnError) {
|
||||
throw 'Error: ' + errorMessage;
|
||||
} else {
|
||||
parsedName.error.push('Error: ' + errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// If fixCase = 1, fix case of parsedName parts before returning
|
||||
function fixParsedNameCase(fixedCaseName, fixCaseNow) {
|
||||
var forceCaseList = ['e', 'y', 'av', 'af', 'da', 'dal', 'de', 'del', 'der', 'di',
|
||||
'la', 'le', 'van', 'der', 'den', 'vel', 'von', 'II', 'III', 'IV', 'J.D.', 'LL.M.',
|
||||
'M.D.', 'D.O.', 'D.C.', 'Ph.D.'];
|
||||
var forceCaseListIndex;
|
||||
var namePartLabels = [];
|
||||
var namePartWords;
|
||||
if (fixCaseNow) {
|
||||
namePartLabels = Object.keys(parsedName)
|
||||
.filter(function (v) { return v !== 'error'; });
|
||||
for (i = 0, l = namePartLabels.length; i < l; i++) {
|
||||
if (fixedCaseName[namePartLabels[i]]) {
|
||||
namePartWords = (fixedCaseName[namePartLabels[i]] + '').split(' ');
|
||||
for (j = 0, m = namePartWords.length; j < m; j++) {
|
||||
forceCaseListIndex = forceCaseList
|
||||
.map(function (v) { return v.toLowerCase(); })
|
||||
.indexOf(namePartWords[j].toLowerCase());
|
||||
if (forceCaseListIndex > -1) { // Set case of words in forceCaseList
|
||||
namePartWords[j] = forceCaseList[forceCaseListIndex];
|
||||
} else if (namePartWords[j].length === 1) { // Uppercase initials
|
||||
namePartWords[j] = namePartWords[j].toUpperCase();
|
||||
} else if (
|
||||
namePartWords[j].length > 2 &&
|
||||
namePartWords[j].slice(0, 1) ===
|
||||
namePartWords[j].slice(0, 1).toUpperCase() &&
|
||||
namePartWords[j].slice(1, 2) ===
|
||||
namePartWords[j].slice(1, 2).toLowerCase() &&
|
||||
namePartWords[j].slice(2) ===
|
||||
namePartWords[j].slice(2).toUpperCase()
|
||||
) { // Detect McCASE and convert to McCase
|
||||
namePartWords[j] = namePartWords[j].slice(0, 3) +
|
||||
namePartWords[j].slice(3).toLowerCase();
|
||||
} else if (
|
||||
namePartLabels[j] === 'suffix' &&
|
||||
nameParts[j].slice(-1) !== '.' &&
|
||||
!suffixList.indexOf(nameParts[j].toLowerCase())
|
||||
) { // Convert suffix abbreviations to UPPER CASE
|
||||
if (namePartWords[j] === namePartWords[j].toLowerCase()) {
|
||||
namePartWords[j] = namePartWords[j].toUpperCase();
|
||||
}
|
||||
} else { // Convert to Title Case
|
||||
namePartWords[j] = namePartWords[j].slice(0, 1).toUpperCase() +
|
||||
namePartWords[j].slice(1).toLowerCase();
|
||||
}
|
||||
}
|
||||
fixedCaseName[namePartLabels[i]] = namePartWords.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return fixedCaseName;
|
||||
}
|
||||
|
||||
// If no input name, or input name is not a string, abort
|
||||
if (!nameToParse || typeof nameToParse !== 'string') {
|
||||
handleError('No input');
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
} else {
|
||||
nameToParse = nameToParse.trim();
|
||||
}
|
||||
|
||||
// Auto-detect fixCase: fix if nameToParse is all upper or all lowercase
|
||||
if (fixCase === -1) {
|
||||
fixCase = (
|
||||
nameToParse === nameToParse.toUpperCase() ||
|
||||
nameToParse === nameToParse.toLowerCase() ? 1 : 0
|
||||
);
|
||||
}
|
||||
|
||||
// Initilize lists of prefixs, suffixs, and titles to detect
|
||||
// Note: These list entries must be all lowercase
|
||||
if (useLongLists) {
|
||||
suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
|
||||
'v', 'clu', 'chfc', 'cfp', 'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.',
|
||||
'p.c.', 'ph.d.'];
|
||||
prefixList = ['a', 'ab', 'antune', 'ap', 'abu', 'al', 'alm', 'alt', 'bab', 'bäck',
|
||||
'bar', 'bath', 'bat', 'beau', 'beck', 'ben', 'berg', 'bet', 'bin', 'bint', 'birch',
|
||||
'björk', 'björn', 'bjur', 'da', 'dahl', 'dal', 'de', 'degli', 'dele', 'del',
|
||||
'della', 'der', 'di', 'dos', 'du', 'e', 'ek', 'el', 'escob', 'esch', 'fleisch',
|
||||
'fitz', 'fors', 'gott', 'griff', 'haj', 'haug', 'holm', 'ibn', 'kauf', 'kil',
|
||||
'koop', 'kvarn', 'la', 'le', 'lind', 'lönn', 'lund', 'mac', 'mhic', 'mic', 'mir',
|
||||
'na', 'naka', 'neder', 'nic', 'ni', 'nin', 'nord', 'norr', 'ny', 'o', 'ua', 'ui\'',
|
||||
'öfver', 'ost', 'över', 'öz', 'papa', 'pour', 'quarn', 'skog', 'skoog', 'sten',
|
||||
'stor', 'ström', 'söder', 'ter', 'ter', 'tre', 'türk', 'van', 'väst', 'väster',
|
||||
'vest', 'von'];
|
||||
titleList = ['mr', 'mrs', 'ms', 'miss', 'dr', 'herr', 'monsieur', 'hr', 'frau',
|
||||
'a v m', 'admiraal', 'admiral', 'air cdre', 'air commodore', 'air marshal',
|
||||
'air vice marshal', 'alderman', 'alhaji', 'ambassador', 'baron', 'barones',
|
||||
'brig', 'brig gen', 'brig general', 'brigadier', 'brigadier general',
|
||||
'brother', 'canon', 'capt', 'captain', 'cardinal', 'cdr', 'chief', 'cik', 'cmdr',
|
||||
'coach', 'col', 'col dr', 'colonel', 'commandant', 'commander', 'commissioner',
|
||||
'commodore', 'comte', 'comtessa', 'congressman', 'conseiller', 'consul',
|
||||
'conte', 'contessa', 'corporal', 'councillor', 'count', 'countess',
|
||||
'crown prince', 'crown princess', 'dame', 'datin', 'dato', 'datuk',
|
||||
'datuk seri', 'deacon', 'deaconess', 'dean', 'dhr', 'dipl ing', 'doctor',
|
||||
'dott', 'dott sa', 'dr', 'dr ing', 'dra', 'drs', 'embajador', 'embajadora', 'en',
|
||||
'encik', 'eng', 'eur ing', 'exma sra', 'exmo sr', 'f o', 'father',
|
||||
'first lieutient', 'first officer', 'flt lieut', 'flying officer', 'fr',
|
||||
'frau', 'fraulein', 'fru', 'gen', 'generaal', 'general', 'governor', 'graaf',
|
||||
'gravin', 'group captain', 'grp capt', 'h e dr', 'h h', 'h m', 'h r h', 'hajah',
|
||||
'haji', 'hajim', 'her highness', 'her majesty', 'herr', 'high chief',
|
||||
'his highness', 'his holiness', 'his majesty', 'hon', 'hr', 'hra', 'ing', 'ir',
|
||||
'jonkheer', 'judge', 'justice', 'khun ying', 'kolonel', 'lady', 'lcda', 'lic',
|
||||
'lieut', 'lieut cdr', 'lieut col', 'lieut gen', 'lord', 'm', 'm l', 'm r',
|
||||
'madame', 'mademoiselle', 'maj gen', 'major', 'master', 'mevrouw', 'miss',
|
||||
'mlle', 'mme', 'monsieur', 'monsignor', 'mr', 'mrs', 'ms', 'mstr', 'nti', 'pastor',
|
||||
'president', 'prince', 'princess', 'princesse', 'prinses', 'prof', 'prof dr',
|
||||
'prof sir', 'professor', 'puan', 'puan sri', 'rabbi', 'rear admiral', 'rev',
|
||||
'rev canon', 'rev dr', 'rev mother', 'reverend', 'rva', 'senator', 'sergeant',
|
||||
'sheikh', 'sheikha', 'sig', 'sig na', 'sig ra', 'sir', 'sister', 'sqn ldr', 'sr',
|
||||
'sr d', 'sra', 'srta', 'sultan', 'tan sri', 'tan sri dato', 'tengku', 'teuku',
|
||||
'than puying', 'the hon dr', 'the hon justice', 'the hon miss', 'the hon mr',
|
||||
'the hon mrs', 'the hon ms', 'the hon sir', 'the very rev', 'toh puan', 'tun',
|
||||
'vice admiral', 'viscount', 'viscountess', 'wg cdr', 'ind', 'misc', 'mx'];
|
||||
} else {
|
||||
suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
|
||||
'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.', 'p.c.', 'ph.d.'];
|
||||
prefixList = ['ab', 'bar', 'bin', 'da', 'dal', 'de', 'de la', 'del', 'della', 'der',
|
||||
'di', 'du', 'ibn', 'l\'', 'la', 'le', 'san', 'st', 'st.', 'ste', 'ter', 'van',
|
||||
'van de', 'van der', 'van den', 'vel', 'ver', 'vere', 'von'];
|
||||
titleList = ['dr', 'miss', 'mr', 'mrs', 'ms', 'prof', 'sir', 'frau', 'herr', 'hr',
|
||||
'monsieur', 'captain', 'doctor', 'judge', 'officer', 'professor', 'ind', 'misc',
|
||||
'mx'];
|
||||
}
|
||||
|
||||
// Nickname: remove and store parts with surrounding punctuation as nicknames
|
||||
regex = /\s(?:[‘’']([^‘’']+)[‘’']|[“”"]([^“”"]+)[“”"]|\[([^\]]+)\]|\(([^\)]+)\)),?\s/g;
|
||||
partFound = (' ' + nameToParse + ' ').match(regex);
|
||||
if (partFound) partsFound = partsFound.concat(partFound);
|
||||
partsFoundCount = partsFound.length;
|
||||
if (partsFoundCount === 1) {
|
||||
parsedName.nick = partsFound[0].slice(2).slice(0, -2);
|
||||
if (parsedName.nick.slice(-1) === ',') {
|
||||
parsedName.nick = parsedName.nick.slice(0, -1);
|
||||
}
|
||||
nameToParse = (' ' + nameToParse + ' ').replace(partsFound[0], ' ').trim();
|
||||
partsFound = [];
|
||||
} else if (partsFoundCount > 1) {
|
||||
handleError(partsFoundCount + ' nicknames found');
|
||||
for (i = 0; i < partsFoundCount; i++) {
|
||||
nameToParse = (' ' + nameToParse + ' ')
|
||||
.replace(partsFound[i], ' ').trim();
|
||||
partsFound[i] = partsFound[i].slice(2).slice(0, -2);
|
||||
if (partsFound[i].slice(-1) === ',') {
|
||||
partsFound[i] = partsFound[i].slice(0, -1);
|
||||
}
|
||||
}
|
||||
parsedName.nick = partsFound.join(', ');
|
||||
partsFound = [];
|
||||
}
|
||||
if (!nameToParse.trim().length) {
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
}
|
||||
|
||||
// Split remaining nameToParse into parts, remove and store preceding commas
|
||||
for (i = 0, n = nameToParse.split(' '), l = n.length; i < l; i++) {
|
||||
part = n[i];
|
||||
comma = null;
|
||||
if (part.slice(-1) === ',') {
|
||||
comma = ',';
|
||||
part = part.slice(0, -1);
|
||||
}
|
||||
nameParts.push(part);
|
||||
nameCommas.push(comma);
|
||||
}
|
||||
|
||||
// Suffix: remove and store matching parts as suffixes
|
||||
for (l = nameParts.length, i = l - 1; i > 0; i--) {
|
||||
partToCheck = (nameParts[i].slice(-1) === '.' ?
|
||||
nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
|
||||
if (
|
||||
suffixList.indexOf(partToCheck) > -1 ||
|
||||
suffixList.indexOf(partToCheck + '.') > -1
|
||||
) {
|
||||
partsFound = nameParts.splice(i, 1).concat(partsFound);
|
||||
if (nameCommas[i] === ',') { // Keep comma, either before or after
|
||||
nameCommas.splice(i + 1, 1);
|
||||
} else {
|
||||
nameCommas.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
partsFoundCount = partsFound.length;
|
||||
if (partsFoundCount === 1) {
|
||||
parsedName.suffix = partsFound[0];
|
||||
partsFound = [];
|
||||
} else if (partsFoundCount > 1) {
|
||||
handleError(partsFoundCount + ' suffixes found');
|
||||
parsedName.suffix = partsFound.join(', ');
|
||||
partsFound = [];
|
||||
}
|
||||
if (!nameParts.length) {
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
}
|
||||
|
||||
// Title: remove and store matching parts as titles
|
||||
for (l = nameParts.length, i = l - 1; i >= 0; i--) {
|
||||
partToCheck = (nameParts[i].slice(-1) === '.' ?
|
||||
nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
|
||||
if (
|
||||
titleList.indexOf(partToCheck) > -1 ||
|
||||
titleList.indexOf(partToCheck + '.') > -1
|
||||
) {
|
||||
partsFound = nameParts.splice(i, 1).concat(partsFound);
|
||||
if (nameCommas[i] === ',') { // Keep comma, either before or after
|
||||
nameCommas.splice(i + 1, 1);
|
||||
} else {
|
||||
nameCommas.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
partsFoundCount = partsFound.length;
|
||||
if (partsFoundCount === 1) {
|
||||
parsedName.title = partsFound[0];
|
||||
partsFound = [];
|
||||
} else if (partsFoundCount > 1) {
|
||||
handleError(partsFoundCount + ' titles found');
|
||||
parsedName.title = partsFound.join(', ');
|
||||
partsFound = [];
|
||||
}
|
||||
if (!nameParts.length) {
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
}
|
||||
|
||||
// Join name prefixes to following names
|
||||
if (nameParts.length > 1) {
|
||||
for (i = nameParts.length - 2; i >= 0; i--) {
|
||||
if (prefixList.indexOf(nameParts[i].toLowerCase()) > -1) {
|
||||
nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1];
|
||||
nameParts.splice(i + 1, 1);
|
||||
nameCommas.splice(i + 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join conjunctions to surrounding names
|
||||
if (nameParts.length > 2) {
|
||||
for (i = nameParts.length - 3; i >= 0; i--) {
|
||||
if (conjunctionList.indexOf(nameParts[i + 1].toLowerCase()) > -1) {
|
||||
nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1] + ' ' + nameParts[i + 2];
|
||||
nameParts.splice(i + 1, 2);
|
||||
nameCommas.splice(i + 1, 2);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Suffix: remove and store items after extra commas as suffixes
|
||||
nameCommas.pop();
|
||||
firstComma = nameCommas.indexOf(',');
|
||||
remainingCommas = nameCommas.filter(function (v) { return v !== null; }).length;
|
||||
if (firstComma > 1 || remainingCommas > 1) {
|
||||
for (i = nameParts.length - 1; i >= 2; i--) {
|
||||
if (nameCommas[i] === ',') {
|
||||
partsFound = nameParts.splice(i, 1).concat(partsFound);
|
||||
nameCommas.splice(i, 1);
|
||||
remainingCommas--;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (partsFound.length) {
|
||||
if (parsedName.suffix) {
|
||||
partsFound = [parsedName.suffix].concat(partsFound);
|
||||
}
|
||||
parsedName.suffix = partsFound.join(', ');
|
||||
partsFound = [];
|
||||
}
|
||||
|
||||
// Last name: remove and store last name
|
||||
if (remainingCommas > 0) {
|
||||
if (remainingCommas > 1) {
|
||||
handleError((remainingCommas - 1) + ' extra commas found');
|
||||
}
|
||||
// Remove and store all parts before first comma as last name
|
||||
if (nameCommas.indexOf(',')) {
|
||||
parsedName.last = nameParts.splice(0, nameCommas.indexOf(',')).join(' ');
|
||||
nameCommas.splice(0, nameCommas.indexOf(','));
|
||||
}
|
||||
} else {
|
||||
// Remove and store last part as last name
|
||||
parsedName.last = nameParts.pop();
|
||||
}
|
||||
if (!nameParts.length) {
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
}
|
||||
|
||||
// First name: remove and store first part as first name
|
||||
parsedName.first = nameParts.shift();
|
||||
if (!nameParts.length) {
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
}
|
||||
|
||||
// Middle name: store all remaining parts as middle name
|
||||
if (nameParts.length > 2) {
|
||||
handleError(nameParts.length + ' middle names');
|
||||
}
|
||||
parsedName.middle = nameParts.join(' ');
|
||||
|
||||
parsedName = fixParsedNameCase(parsedName, fixCase);
|
||||
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
const { cleanString } = require('./index')
|
||||
|
||||
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
|
||||
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
|
||||
const INFO_FORMATS = ['nfo']
|
||||
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
|
||||
const EBOOK_FORMATS = ['epub', 'pdf']
|
||||
@@ -22,7 +23,7 @@ function getPaths(path) {
|
||||
function getFileType(ext) {
|
||||
var ext_cleaned = ext.toLowerCase()
|
||||
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
|
||||
if (AUDIOBOOK_PARTS_FORMATS.includes(ext_cleaned)) return 'abpart'
|
||||
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'
|
||||
@@ -34,17 +35,19 @@ async function getAllAudiobookFiles(abRootPath) {
|
||||
var audiobooks = {}
|
||||
|
||||
paths.files.forEach((filepath) => {
|
||||
var relpath = filepath.replace(abRootPath, '').slice(1)
|
||||
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
|
||||
var pathformat = Path.parse(relpath)
|
||||
var path = pathformat.dir
|
||||
|
||||
// If relative file directory has 3 folders, then the middle folder will be series
|
||||
var splitDir = pathformat.dir.split(Path.sep)
|
||||
if (splitDir.length === 1) {
|
||||
Logger.error('Invalid file in root dir', filepath)
|
||||
if (!path) {
|
||||
Logger.error('Ignoring file in root dir', filepath)
|
||||
return
|
||||
}
|
||||
var author = splitDir.shift()
|
||||
|
||||
// If relative file directory has 3 folders, then the middle folder will be series
|
||||
var splitDir = pathformat.dir.split(Path.sep)
|
||||
var author = null
|
||||
if (splitDir.length > 1) author = splitDir.shift()
|
||||
var series = null
|
||||
if (splitDir.length > 1) series = splitDir.shift()
|
||||
var title = splitDir.shift()
|
||||
@@ -64,26 +67,24 @@ async function getAllAudiobookFiles(abRootPath) {
|
||||
audiobooks[path] = {
|
||||
author: author,
|
||||
title: title,
|
||||
series: series,
|
||||
series: cleanString(series),
|
||||
publishYear: publishYear,
|
||||
path: relpath,
|
||||
path: path,
|
||||
fullPath: Path.join(abRootPath, path),
|
||||
parts: [],
|
||||
audioFiles: [],
|
||||
otherFiles: []
|
||||
}
|
||||
}
|
||||
|
||||
var filetype = getFileType(pathformat.ext)
|
||||
if (filetype === 'abpart') {
|
||||
audiobooks[path].parts.push(pathformat.base)
|
||||
var fileObj = {
|
||||
filetype: getFileType(pathformat.ext),
|
||||
filename: pathformat.base,
|
||||
path: relpath,
|
||||
fullPath: filepath,
|
||||
ext: pathformat.ext
|
||||
}
|
||||
if (fileObj.filetype === 'audio') {
|
||||
audiobooks[path].audioFiles.push(fileObj)
|
||||
} else {
|
||||
var fileObj = {
|
||||
filetype: filetype,
|
||||
filename: pathformat.base,
|
||||
path: relpath,
|
||||
fullPath: filepath,
|
||||
ext: pathformat.ext
|
||||
}
|
||||
audiobooks[path].otherFiles.push(fileObj)
|
||||
}
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |