Compare commits

...

39 Commits

Author SHA1 Message Date
Mark Cooper 197012e662 Version 1.0.0, updating readme 2021-09-04 14:35:25 -05:00
Mark Cooper e4dac5dd05 Adding download tab and download manager, ffmpeg in worker thread 2021-09-04 14:17:26 -05:00
Mark Cooper a86bda59f6 Fix server client.user undefined, update logo 2021-09-03 06:40:59 -05:00
Mark Cooper 5b1269cbe8 fix variable typo 2021-09-01 19:50:18 -05:00
Mark Cooper c81a0260e2 Emit stream_closed even if stream is not found 2021-09-01 19:39:38 -05:00
Mark Cooper 2b1e6b0c3b Update ver 2021-09-01 16:01:53 -05:00
Mark Cooper bff42962ba Logo update, fix book card shadow 2021-09-01 16:01:15 -05:00
Mark Cooper 73e2f184cf Remove test fonts 2021-09-01 13:50:37 -05:00
Mark Cooper 0c017c4227 Fix multi-select, add new book flag 2021-09-01 13:47:18 -05:00
Mark Cooper 234653b549 Add m4a filetype 2021-08-27 14:35:16 -05:00
Mark Cooper 23f343f1df Adding and deleting users 2021-08-27 07:01:47 -05:00
Mark Cooper 88c7c1632e Batch updating and deleting, multi-select 2021-08-26 18:32:05 -05:00
Mark Cooper 8c9fb0d45e Fix set card size index on mount 2021-08-26 10:43:46 -05:00
Mark Cooper e54535f465 Bookshelf cover size setting and widget 2021-08-26 09:47:51 -05:00
Mark Cooper 46d7c45ca5 Cover image aspect ratio solution 2021-08-26 08:04:52 -05:00
Mark Cooper 9c32e4cbda Fix update tracklist and invalid parts alert, update readme screenshots 2021-08-26 07:09:23 -05:00
Mark Cooper dc0f25aca3 Sync tracks always 2021-08-25 19:32:17 -05:00
Mark Cooper 93c78a672c Fix listener for audiobook updates in edit modal, add remove cover button 2021-08-25 19:15:00 -05:00
Mark Cooper 63cae5b0ed Adding inode to files and audiobooks to support renaming, setting up watcher and removing chokidar 2021-08-25 17:36:54 -05:00
Mark Cooper 81487d1dba Parse and update author name on each update 2021-08-25 06:38:32 -05:00
Mark Cooper 6ca7e9e6a6 Emit update when cover is updated 2021-08-24 20:32:13 -05:00
Mark Cooper e230cb47e8 Clean and parse author name from directory, sort by author last name, scan for covers 2021-08-24 20:24:40 -05:00
Mark Cooper 9300a0bfb6 Fix ab undefined 2021-08-24 08:04:32 -05:00
Mark Cooper 3d64115051 Fix incorrect audiobook file paths before a scan 2021-08-24 07:50:36 -05:00
Mark Cooper 2f215ed2e5 package lock sync 2021-08-24 07:36:20 -05:00
Mark Cooper bda0c0c804 Scanner update - remove and update audiobooks on scans 2021-08-24 07:15:56 -05:00
Mark Cooper bf38004b5e Fix dynamic route requests, add auth middleware 2021-08-23 19:37:40 -05:00
Mark Cooper f83c5dd440 Moving settings to be user specific, adding playbackRate setting, update playbackRate picker to go up to 3x 2021-08-23 18:31:04 -05:00
Mark Cooper 2548aba840 Update readme 2021-08-23 14:20:33 -05:00
Mark Cooper 5803559183 Increment beta verison 2021-08-23 14:14:19 -05:00
Mark Cooper 73a786879e Fix scan for audiobook directories in root dir 2021-08-23 14:08:54 -05:00
Mark Cooper f4cb5d101e Reset password and users table on settings page 2021-08-22 10:46:04 -05:00
Mark Cooper 9331b5870f remove comment 2021-08-22 09:26:03 -05:00
Mark Cooper dd213ddfd1 Series as a dropdown and filter, fix genre list in details modal 2021-08-22 08:52:37 -05:00
Mark Cooper 0990c61c93 Add global search, add reset all audiobooks 2021-08-21 16:23:35 -05:00
Mark Cooper 5b64453101 Removing release-it 2021-08-21 14:02:10 -05:00
Mark Cooper cbf2938c9c Release 0.9.61-beta.0 2021-08-21 13:28:32 -05:00
Mark Cooper 04643ad686 update release-update command 2021-08-21 13:27:46 -05:00
Mark Cooper 13cd5a4041 release-update command 2021-08-21 13:26:43 -05:00
95 changed files with 4802 additions and 3131 deletions
+3 -2
View File
@@ -8,5 +8,6 @@ npm-debug.log
/audiobooks2 /audiobooks2
/metadata /metadata
dev.js dev.js
/test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/
+3 -2
View File
@@ -5,5 +5,6 @@ node_modules/
/audiobooks/ /audiobooks/
/audiobooks2/ /audiobooks2/
/metadata/ /metadata/
/test/ test/
/client/.nuxt/ /client/.nuxt/
/client/dist/
-8
View File
@@ -1,8 +0,0 @@
{
"github": {
"release": true
},
"npm": {
"publish": false
}
}
+32
View File
@@ -61,4 +61,36 @@
border-left: 6px solid transparent; border-left: 6px solid transparent;
border-right: 6px solid transparent; border-right: 6px solid transparent;
border-top: 6px solid white; 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 -6
View File
@@ -27,7 +27,7 @@
<div class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="forward10"> <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> <span class="material-icons text-3xl">forward_10</span>
</div> </div>
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" /> <controls-playback-speed-control v-model="playbackRate" @change="playbackRateChanged" />
</template> </template>
<template v-else> <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"> <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: { computed: {
token() { token() {
return this.$store.getters.getToken return this.$store.getters['user/getToken']
}, },
totalDurationPretty() { totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration) return this.$secondsToTimestamp(this.totalDuration)
@@ -130,12 +130,22 @@ export default {
}, },
updatePlaybackRate(playbackRate) { updatePlaybackRate(playbackRate) {
if (this.audioEl) { if (this.audioEl) {
console.log('UpdatePlaybackRate', playbackRate) try {
this.audioEl.playbackRate = playbackRate this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else { } else {
console.error('No Audio El updatePlaybackRate') 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) { mousemoveTrack(e) {
var offsetX = e.offsetX var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration var time = (offsetX / this.trackWidth) * this.totalDuration
@@ -355,7 +365,8 @@ export default {
this.hlsInstance = new Hls(hlsOptions) this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio var audio = this.$refs.audio
audio.volume = this.volume audio.volume = this.volume
audio.playbackRate = this.playbackRate audio.defaultPlaybackRate = this.playbackRate
this.hlsInstance.attachMedia(audio) this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => { this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED') // console.log('[HLS] MEDIA ATTACHED')
@@ -410,17 +421,27 @@ export default {
this.set(this.url, startTime, true) this.set(this.url, startTime, true)
}, },
init() { init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio this.audioEl = this.$refs.audio
if (this.$refs.track) { if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth this.trackWidth = this.$refs.track.clientWidth
} else { } else {
console.error('Track not loaded', this.$refs) console.error('Track not loaded', this.$refs)
} }
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
} }
}, },
mounted() { mounted() {
// this.$nextTick(this.init) this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init() this.init()
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
} }
} }
</script> </script>
+74 -17
View File
@@ -2,21 +2,31 @@
<div class="w-full h-16 bg-primary relative"> <div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30"> <div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
<div class="flex h-full items-center"> <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"> <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> <span class="material-icons text-4xl text-white">arrow_back</span>
</a> </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" /> <div class="flex-grow" />
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> --> <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">
<nuxt-link 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> <span class="material-icons">settings</span>
</nuxt-link> </nuxt-link>
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" /> <ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
</div> </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>
</div> </div>
</template> </template>
@@ -26,15 +36,17 @@ export default {
data() { data() {
return { return {
menuItems: [ menuItems: [
// { {
// value: 'settings', value: 'account',
// text: 'Settings' text: 'Account',
// }, to: '/account'
},
{ {
value: 'logout', value: 'logout',
text: 'Logout' text: 'Logout'
} }
] ],
processingBatchDelete: false
} }
}, },
computed: { computed: {
@@ -42,10 +54,25 @@ export default {
return this.$route.name !== 'index' return this.$route.name !== 'index'
}, },
user() { user() {
return this.$store.state.user return this.$store.state.user.user
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
}, },
username() { username() {
return this.user ? this.user.username : 'err' 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: { methods: {
@@ -56,10 +83,6 @@ export default {
this.$router.push('/') this.$router.push('/')
} }
}, },
scan() {
console.log('Call Start Init')
this.$root.socket.emit('scan')
},
logout() { logout() {
this.$axios.$post('/logout').catch((error) => { this.$axios.$post('/logout').catch((error) => {
console.error(error) console.error(error)
@@ -72,9 +95,44 @@ export default {
menuAction(action) { menuAction(action) {
if (action === 'logout') { if (action === 'logout') {
this.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() {} mounted() {}
@@ -83,7 +141,6 @@ export default {
<style> <style>
#appbar { #appbar {
/* box-shadow: 0px 8px 8px #111111aa; */
box-shadow: 0px 5px 5px #11111155; box-shadow: 0px 5px 5px #11111155;
} }
</style> </style>
+56 -10
View File
@@ -1,5 +1,14 @@
<template> <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"> <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> <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> <ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
@@ -9,7 +18,7 @@
<div :key="index" class="w-full bookshelfRow relative"> <div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center"> <div class="flex justify-center items-center">
<template v-for="audiobook in shelf"> <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> </template>
</div> </div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" /> <div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
@@ -24,24 +33,51 @@ export default {
data() { data() {
return { return {
width: 0, width: 0,
bookWidth: 176,
booksPerRow: 0, booksPerRow: 0,
groupedBooks: [], groupedBooks: [],
currFilterOrderKey: null currFilterOrderKey: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40
} }
}, },
computed: { computed: {
userAudiobooks() { userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
}, },
audiobooks() { audiobooks() {
return this.$store.state.audiobooks.audiobooks return this.$store.state.audiobooks.audiobooks
}, },
filterOrderKey() { 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: { 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() { setGroupedBooks() {
var groups = [] var groups = []
var currentRow = 0 var currentRow = 0
@@ -66,6 +102,7 @@ export default {
}, },
calculateBookshelf() { calculateBookshelf() {
this.width = this.$refs.wrapper.clientWidth this.width = this.$refs.wrapper.clientWidth
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
var booksPerRow = Math.floor(this.width / this.bookWidth) var booksPerRow = Math.floor(this.width / this.bookWidth)
this.booksPerRow = booksPerRow this.booksPerRow = booksPerRow
}, },
@@ -76,6 +113,9 @@ export default {
return null return null
}, },
init() { 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() this.calculateBookshelf()
}, },
resize() { resize() {
@@ -88,11 +128,17 @@ export default {
console.log('[AudioBookshelf] Audiobooks Updated') console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks() this.setGroupedBooks()
}, },
settingsUpdated() { settingsUpdated(settings) {
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
if (this.currFilterOrderKey !== this.filterOrderKey) { if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks() 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() { scan() {
this.$root.socket.emit('scan') this.$root.socket.emit('scan')
@@ -100,7 +146,7 @@ export default {
}, },
mounted() { mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated }) 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.$store.dispatch('audiobooks/load')
this.init() this.init()
@@ -108,7 +154,7 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf') this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('settings/removeListener', 'bookshelf') this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize) window.removeEventListener('resize', this.resize)
} }
} }
+16 -6
View File
@@ -3,9 +3,9 @@
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8"> <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<p class="font-book">{{ numShowing }} Audiobooks</p> <p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" /> <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> <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>
</div> </div>
</template> </template>
@@ -14,7 +14,8 @@
export default { export default {
data() { data() {
return { return {
settings: {} settings: {},
hasInit: false
} }
}, },
computed: { computed: {
@@ -30,15 +31,24 @@ export default {
this.saveSettings() this.saveSettings()
}, },
saveSettings() { saveSettings() {
// Send to server this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.commit('settings/setSettings', this.settings) this.$store.dispatch('user/updateUserSettings', this.settings)
}, },
init() { 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() { mounted() {
this.init() this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
} }
} }
</script> </script>
+6 -4
View File
@@ -1,5 +1,5 @@
<template> <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"> <div class="absolute -top-16 left-4">
<cards-book-cover :audiobook="streamAudiobook" :width="88" /> <cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div> </div>
@@ -22,6 +22,7 @@
export default { export default {
data() { data() {
return { return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0, lastServerUpdateSentSeconds: 0,
stream: null stream: null
} }
@@ -32,7 +33,7 @@ export default {
return 'Logo.png' return 'Logo.png'
}, },
user() { user() {
return this.$store.state.user return this.$store.state.user.user
}, },
isLoading() { isLoading() {
if (!this.streamAudiobook) return false if (!this.streamAudiobook) return false
@@ -63,6 +64,7 @@ export default {
}, },
methods: { methods: {
audioPlayerMounted() { audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) { if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream) console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
this.openStream() this.openStream()
@@ -104,12 +106,12 @@ export default {
if (this.$refs.audioPlayer) { if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream) console.log('[STREAM-CONTAINER] streamOpen', stream)
this.openStream() this.openStream()
} else { } else if (this.audioPlayerReady) {
console.error('No Audio Ref') console.error('No Audio Ref')
} }
}, },
streamClosed(streamId) { streamClosed(streamId) {
if (this.stream && this.stream.id === streamId) { if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream() this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id) this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null 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>
+102 -28
View File
@@ -1,29 +1,42 @@
<template> <template>
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4"> <div class="relative">
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false"> <!-- New Book Flag -->
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }"> <div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<cards-book-cover :audiobook="audiobook" /> <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="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<div class="h-full flex items-center justify-center"> <nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play"> <div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
<span class="material-icons text-5xl">play_circle_filled</span> <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> </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"> <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
<span class="material-icons" style="font-size: 16px">edit</span>
</div>
</div>
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
</div> <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">
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0"> <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
<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"> </div>
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span> </ui-tooltip>
</div> </div>
</ui-tooltip> </nuxt-link>
</div> </div>
</nuxt-link> </div>
</template> </template>
<script> <script>
@@ -36,6 +49,10 @@ export default {
userProgress: { userProgress: {
type: Object, type: Object,
default: () => null default: () => null
},
width: {
type: Number,
default: 120
} }
}, },
data() { data() {
@@ -44,24 +61,67 @@ export default {
} }
}, },
computed: { computed: {
isNew() {
return this.tags.includes('new')
},
tags() {
return this.audiobook.tags || []
},
audiobookId() { audiobookId() {
return this.audiobook.id 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() { book() {
return this.audiobook.book || {} return this.audiobook.book || {}
}, },
width() {
return 120
},
height() { height() {
return this.width * 1.6 return this.width * 1.6
}, },
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
title() { title() {
return this.book.title return this.book.title
}, },
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() { author() {
return this.book.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() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.userProgress ? this.userProgress.progress || 0 : 0
}, },
@@ -84,9 +144,22 @@ export default {
txt += `${this.hasInvalidParts} invalid parts.` txt += `${this.hasInvalidParts} invalid parts.`
} }
return txt || 'Unknown Error' 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: { methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
clickError(e) { clickError(e) {
e.stopPropagation() e.stopPropagation()
this.$router.push(`/audiobook/${this.audiobookId}`) this.$router.push(`/audiobook/${this.audiobookId}`)
@@ -97,14 +170,15 @@ export default {
}, },
editClick() { editClick() {
this.$store.commit('showEditModal', this.audiobook) this.$store.commit('showEditModal', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
}
} }
}, },
mounted() {} mounted() {}
} }
</script> </script>
<style>
.bookCard {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
</style>
+57 -4
View File
@@ -1,13 +1,19 @@
<template> <template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }"> <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 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"> <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> <p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div> </div>
</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 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> <div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p> <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, type: Object,
default: () => {} default: () => {}
}, },
authorOverride: String,
width: { width: {
type: Number, type: Number,
default: 120 default: 120
@@ -33,7 +40,13 @@ export default {
}, },
data() { data() {
return { return {
imageFailed: false imageFailed: false,
showCoverBg: false
}
},
watch: {
cover() {
this.imageFailed = false
} }
}, },
computed: { computed: {
@@ -50,6 +63,7 @@ export default {
return this.title return this.title
}, },
author() { author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown' return this.book.author || 'Unknown'
}, },
authorCleaned() { authorCleaned() {
@@ -58,8 +72,22 @@ export default {
} }
return this.author 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() { cover() {
return this.book.cover || '/book_placeholder.jpg' return this.book.cover || this.placeholderUrl
}, },
hasCover() { hasCover() {
return !!this.book.cover return !!this.book.cover
@@ -81,6 +109,31 @@ export default {
} }
}, },
methods: { 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) { imageError(err) {
console.error('ImgError', err) console.error('ImgError', err)
this.imageFailed = true this.imageFailed = true
+18 -2
View File
@@ -1,14 +1,17 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <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="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</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"> <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" /> <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> </svg>
</span> </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> </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"> <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', text: 'Tag',
value: 'tags', value: 'tags',
sublist: true sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
} }
] ]
} }
@@ -113,11 +121,19 @@ export default {
tags() { tags() {
return this.$store.state.audiobooks.tags return this.$store.state.audiobooks.tags
}, },
series() {
return this.$store.state.audiobooks.series
},
sublistItems() { sublistItems() {
return this[this.sublist] || [] return this[this.sublist] || []
} }
}, },
methods: { methods: {
clearSelected() {
this.selected = 'all'
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
snakeToNormal(kebab) { snakeToNormal(kebab) {
if (!kebab) { if (!kebab) {
return 'err' return 'err'
+112
View File
@@ -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>
+10 -10
View File
@@ -1,22 +1,17 @@
<template> <template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside"> <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="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span> <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 class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</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> </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"> <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"> <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)"> <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"> <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> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <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> <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@@ -42,8 +37,12 @@ export default {
value: 'book.title' value: 'book.title'
}, },
{ {
text: 'Author', text: 'Author (First Last)',
value: 'book.author' value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
}, },
{ {
text: 'Added At', text: 'Added At',
@@ -78,7 +77,8 @@ export default {
} }
}, },
selectedText() { 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 '' if (!_sel) return ''
return _sel.text return _sel.text
} }
@@ -8,12 +8,24 @@
<div class="arrow-down" /> <div class="arrow-down" />
</div> </div>
<div class="w-full h-full no-scroll flex"> <div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
<template v-for="(rate, index) in rates"> <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">
<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)"> <span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm"></span></p> </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> </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> </div>
</div> </div>
@@ -29,7 +41,9 @@ export default {
}, },
data() { data() {
return { return {
showMenu: false showMenu: false,
rateIndex: 1,
numVisible: 5
} }
}, },
computed: { computed: {
@@ -42,7 +56,10 @@ export default {
} }
}, },
rates() { 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: { methods: {
@@ -55,6 +72,12 @@ export default {
this.playbackRate = newPlaybackRate this.playbackRate = newPlaybackRate
if (hasChanged) this.$emit('change', newPlaybackRate) if (hasChanged) this.$emit('change', newPlaybackRate)
this.showMenu = false 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() {} mounted() {}
+131
View File
@@ -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>
+43 -14
View File
@@ -6,10 +6,9 @@
</div> </div>
</template> </template>
<div class="absolute -top-10 left-0 w-full flex"> <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> <template v-for="tab in tabs">
<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 :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>
<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> </template>
<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>
</div> </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"> <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> <keep-alive>
@@ -25,18 +24,48 @@ export default {
return { return {
selectedTab: 'details', selectedTab: 'details',
processing: false, 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: { watch: {
show: { show: {
handler(newVal) { handler(newVal) {
if (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.audiobook = null
this.init() this.init()
} else {
this.$store.commit('audiobooks/removeListener', 'edit-modal')
} }
} }
} }
@@ -51,11 +80,8 @@ export default {
} }
}, },
tabName() { tabName() {
if (this.selectedTab === 'details') return 'modals-edit-tabs-details' var _tab = this.tabs.find((t) => t.id === this.selectedTab)
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover' return _tab ? _tab.component : ''
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
return ''
}, },
selectedAudiobook() { selectedAudiobook() {
return this.$store.state.selectedAudiobook || {} return this.$store.state.selectedAudiobook || {}
@@ -75,7 +101,10 @@ export default {
this.selectedTab = tab this.selectedTab = tab
}, },
audiobookUpdated() { audiobookUpdated() {
this.fetchFull() if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
}, },
init() { init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId }) this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
+1 -1
View File
@@ -1,5 +1,5 @@
<template> <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-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"> <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">
+67 -8
View File
@@ -1,7 +1,16 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="flex"> <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"> <div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div class="flex items-center"> <div class="flex items-center">
@@ -10,6 +19,24 @@
</div> </div>
</form> </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"> <form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2"> <div class="flex items-center justify-start -mx-1 py-2 mt-2">
<div class="flex-grow px-1"> <div class="flex-grow px-1">
@@ -23,11 +50,14 @@
</div> </div>
</div> </div>
</form> </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> <p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound"> <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)"> <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> </div>
</template> </template>
</div> </div>
@@ -37,6 +67,8 @@
</template> </template>
<script> <script>
import Path from 'path'
export default { export default {
props: { props: {
processing: Boolean, processing: Boolean,
@@ -51,14 +83,17 @@ export default {
searchAuthor: null, searchAuthor: null,
imageUrl: null, imageUrl: null,
coversFound: [], coversFound: [],
hasSearched: false hasSearched: false,
showLocalCovers: false
} }
}, },
watch: { watch: {
audiobook: { audiobook: {
immediate: true, immediate: true,
handler(newVal) { handler(newVal) {
if (newVal) this.init() if (newVal) {
this.init()
}
} }
} }
}, },
@@ -73,10 +108,23 @@ export default {
}, },
book() { book() {
return this.audiobook ? this.audiobook.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: { methods: {
init() { init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) { if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
this.coversFound = [] this.coversFound = []
this.hasSearched = false this.hasSearched = false
@@ -85,10 +133,22 @@ export default {
this.searchTitle = this.book.title || '' this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || '' this.searchAuthor = this.book.author || ''
}, },
removeCover() {
if (!this.book.cover) {
this.imageUrl = ''
return
}
this.updateCover('')
},
submitForm() { submitForm() {
this.updateCover(this.imageUrl) this.updateCover(this.imageUrl)
}, },
async updateCover(cover) { async updateCover(cover) {
if (cover === this.book.cover) {
console.warn('Cover has not changed..', cover)
return
}
this.isProcessing = true this.isProcessing = true
const updatePayload = { const updatePayload = {
book: { book: {
@@ -101,13 +161,12 @@ export default {
}) })
this.isProcessing = false this.isProcessing = false
if (updatedAudiobook) { if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful') this.$toast.success('Update Successful')
this.$emit('close') this.$emit('close')
} }
}, },
getSearchQuery() { getSearchQuery() {
var searchQuery = `provider=best&title=${this.searchTitle}` var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}` if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery return searchQuery
}, },
+38 -14
View File
@@ -21,11 +21,25 @@
</div> </div>
</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-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"> <div class="flex py-4">
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> <ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
@@ -52,11 +66,12 @@ export default {
description: null, description: null,
author: null, author: null,
series: null, series: null,
volumeNumber: null,
publishYear: null, publishYear: null,
genres: [] genres: []
}, },
resettingProgress: false, newTags: [],
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'] resettingProgress: false
} }
}, },
watch: { watch: {
@@ -83,32 +98,38 @@ export default {
return this.audiobook ? this.audiobook.book || {} : {} return this.audiobook ? this.audiobook.book || {} : {}
}, },
userAudiobook() { userAudiobook() {
return this.$store.getters['getUserAudiobook'](this.audiobookId) return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
}, },
userProgress() { userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0 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: { methods: {
addGenre(genre) {
this.genres.push({
text: genre,
value: genre
})
},
async submitForm() { async submitForm() {
console.log('Submit form', this.details) if (this.isProcessing) {
return
}
this.isProcessing = true this.isProcessing = true
const updatePayload = { 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) => { var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error) console.error('Failed to update', error)
return false return false
}) })
this.isProcessing = false this.isProcessing = false
if (updatedAudiobook) { if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful') this.$toast.success('Update Successful')
this.$emit('close') this.$emit('close')
} }
@@ -119,7 +140,10 @@ export default {
this.details.author = this.book.author this.details.author = this.book.author
this.details.genres = this.book.genres || [] this.details.genres = this.book.genres || []
this.details.series = this.book.series this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear this.details.publishYear = this.book.publishYear
this.newTags = this.audiobook.tags || []
}, },
resetProgress() { resetProgress() {
if (confirm(`Are you sure you want to reset your progress?`)) { 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>
+13 -5
View File
@@ -15,7 +15,7 @@
<div v-show="processing" class="flex h-full items-center justify-center"> <div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p> <p>Loading...</p>
</div> </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> <p>No Results</p>
</div> </div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper"> <div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
@@ -37,11 +37,13 @@ export default {
}, },
data() { data() {
return { return {
audiobookId: null,
searchTitle: null, searchTitle: null,
searchAuthor: null, searchAuthor: null,
lastSearch: null, lastSearch: null,
provider: 'best', provider: 'best',
searchResults: [] searchResults: [],
hasSearched: false
} }
}, },
watch: { watch: {
@@ -64,7 +66,7 @@ export default {
}, },
methods: { methods: {
getSearchQuery() { 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}` if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery return searchQuery
}, },
@@ -90,15 +92,22 @@ export default {
}) })
this.searchResults = results this.searchResults = results
this.isProcessing = false this.isProcessing = false
this.hasSearched = true
}, },
init() { 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) { if (!this.audiobook.book || !this.audiobook.book.title) {
this.searchTitle = null this.searchTitle = null
this.searchAuthor = null
return return
} }
this.searchTitle = this.audiobook.book.title this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || '' this.searchAuthor = this.audiobook.book.author || ''
this.runSearch()
}, },
async selectMatch(match) { async selectMatch(match) {
this.isProcessing = true this.isProcessing = true
@@ -120,7 +129,6 @@ export default {
}) })
this.isProcessing = false this.isProcessing = false
if (updatedAudiobook) { if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful') this.$toast.success('Update Successful')
this.$emit('close') this.$emit('close')
} }
+15 -3
View File
@@ -1,6 +1,12 @@
<template> <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 /> <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> </button>
</template> </template>
@@ -16,7 +22,9 @@ export default {
default: '' default: ''
}, },
paddingX: Number, paddingX: Number,
small: Boolean small: Boolean,
loading: Boolean,
disabled: Boolean
}, },
data() { data() {
return {} return {}
@@ -24,6 +32,7 @@ export default {
computed: { computed: {
classList() { classList() {
var list = [] var list = []
if (this.loading) list.push('text-opacity-0')
list.push('text-white') list.push('text-white')
list.push(`bg-${this.color}`) list.push(`bg-${this.color}`)
if (this.small) { if (this.small) {
@@ -61,7 +70,10 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0); background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out; 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); background-color: rgba(255, 255, 255, 0.1);
} }
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style> </style>
+124
View File
@@ -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>
+8 -1
View File
@@ -12,7 +12,14 @@
<transition name="menu"> <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"> <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"> <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"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span> <span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div> </div>
+25 -33
View File
@@ -4,7 +4,12 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <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 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" /> <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> </div>
</form> </form>
@@ -13,7 +18,7 @@
<template v-for="item in itemsToShow"> <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> <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"> <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> </div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <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> <span class="material-icons text-xl">checkmark</span>
@@ -47,7 +52,6 @@ export default {
return { return {
textInput: null, textInput: null,
currentSearch: null, currentSearch: null,
isTyping: false,
typingTimeout: null, typingTimeout: null,
isFocused: false, isFocused: false,
menu: null menu: null
@@ -71,38 +75,15 @@ export default {
} }
return this.items.filter((i) => { return this.items.filter((i) => {
var normie = this.snakeToNormal(i) var normie = this.$snakeToNormal(i)
var iValue = String(normie).toLowerCase() var iValue = String(normie).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase()) return iValue.includes(this.currentSearch.toLowerCase())
}) })
} }
}, },
methods: { 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() { keydownInput() {
clearTimeout(this.typingTimeout) clearTimeout(this.typingTimeout)
this.isTyping = true
this.typingTimeout = setTimeout(() => { this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput this.currentSearch = this.textInput
}, 100) }, 100)
@@ -156,8 +137,10 @@ export default {
if (this.$refs.input) this.$refs.input.blur() if (this.$refs.input) this.$refs.input.blur()
}, },
clickedOption(e, itemValue) { clickedOption(e, itemValue) {
e.stopPropagation() if (e) {
e.preventDefault() e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus() if (this.$refs.input) this.$refs.input.focus()
var newSelected = null var newSelected = null
@@ -169,6 +152,9 @@ export default {
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
this.$emit('input', newSelected) this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
}, },
clickWrapper() { clickWrapper() {
if (this.showMenu) { if (this.showMenu) {
@@ -176,10 +162,16 @@ export default {
} }
this.focus() this.focus()
}, },
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) { insertNewItem(item) {
var kebabItem = this.normalToSnake(item) var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem) this.selected.push(kebabItem)
this.$emit('addOption', kebabItem)
this.$emit('input', this.selected) this.$emit('input', this.selected)
this.textInput = null this.textInput = null
this.currentSearch = null this.currentSearch = null
@@ -191,12 +183,12 @@ export default {
if (!this.textInput) return if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim() var cleaned = this.textInput.toLowerCase().trim()
var cleanedKebab = this.normalToSnake(cleaned) var cleanedKebab = this.$normalToSnake(cleaned)
var matchesItem = this.items.find((i) => { var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i return i === cleaned || cleanedKebab === i
}) })
if (matchesItem) { if (matchesItem) {
this.clickedOption(matchesItem.value) this.clickedOption(null, matchesItem)
} else { } else {
this.insertNewItem(this.textInput) this.insertNewItem(this.textInput)
} }
+10 -1
View File
@@ -1,5 +1,5 @@
<template> <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> </template>
<script> <script>
@@ -29,8 +29,17 @@ export default {
} }
}, },
methods: { methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) { change(e) {
this.$emit('change', e.target.value) this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
} }
}, },
mounted() {} mounted() {}
+34
View File
@@ -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>
+14 -4
View File
@@ -10,6 +10,10 @@ export default {
text: { text: {
type: String, type: String,
required: true required: true
},
direction: {
type: String,
default: 'right'
} }
}, },
data() { data() {
@@ -21,11 +25,17 @@ export default {
methods: { methods: {
createTooltip() { createTooltip() {
var boxChow = this.$refs.box.getBoundingClientRect() var boxChow = this.$refs.box.getBoundingClientRect()
var top = boxChow.top var top = 0
var left = boxChow.left + boxChow.width + 4 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') 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.top = top + 'px'
tooltip.style.left = left + 'px' tooltip.style.left = left + 'px'
tooltip.style.zIndex = 100 tooltip.style.zIndex = 100
+32 -5
View File
@@ -1,25 +1,47 @@
<template> <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"> <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" /> <p class="text-lg font-sans" v-html="text" />
</div> </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> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
}, },
computed: { computed: {
text() { 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() { isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning return this.$store.state.isScanning
}, },
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() { scanProgress() {
return this.$store.state.scanProgress return this.$store.state[this.scanProgressKey]
}, },
scanPercent() { scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%' return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
@@ -31,7 +53,12 @@ export default {
return this.scanProgress ? this.scanProgress.total : 0 return this.scanProgress ? this.scanProgress.total : 0
} }
}, },
methods: {}, methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {} mounted() {}
} }
</script> </script>
+87 -23
View File
@@ -10,6 +10,7 @@
<script> <script>
export default { export default {
middleware: 'authenticated',
data() { data() {
return { return {
socket: null socket: null
@@ -20,17 +21,20 @@ export default {
if (this.$store.state.showEditModal) { if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false) this.$store.commit('setShowEditModal', false)
} }
if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', [])
}
} }
}, },
computed: { computed: {
user() { user() {
return this.$store.state.user return this.$store.state.user.user
} }
}, },
methods: { methods: {
connect() { connect() {
console.log('[SOCKET] Connected') console.log('[SOCKET] Connected')
var token = this.$store.getters.getToken var token = this.$store.getters['user/getToken']
this.socket.emit('auth', token) this.socket.emit('auth', token)
}, },
connectError() {}, connectError() {},
@@ -49,7 +53,8 @@ export default {
} }
} }
if (payload.user) { 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) { streamOpen(stream) {
@@ -81,21 +86,79 @@ export default {
} }
this.$store.commit('audiobooks/remove', audiobook) this.$store.commit('audiobooks/remove', audiobook)
}, },
scanComplete() { scanComplete({ scanType, results }) {
this.$store.commit('setIsScanning', false) if (scanType === 'covers') {
this.$toast.success('Scan Finished') 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() { scanStart(scanType) {
this.$store.commit('setIsScanning', true) if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
}, },
scanProgress(progress) { scanProgress({ scanType, progress }) {
this.$store.commit('setScanProgress', progress) if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
} else {
this.$store.commit('setScanProgress', progress)
}
}, },
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {
this.$store.commit('setUser', user) 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() { initializeSocket() {
this.socket = this.$nuxtSocket({ this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod', name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -136,21 +199,22 @@ export default {
this.socket.on('scan_start', this.scanStart) this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete) this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress) this.socket.on('scan_progress', this.scanProgress)
},
checkVersion() { // Download Listeners
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => { this.socket.on('download_started', this.downloadStarted)
console.log('GOT DATA', data) 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)
beforeMount() {
if (!this.$store.state.user) {
this.$router.replace(`/login?redirect=${this.$route.path}`)
} }
}, },
mounted() { mounted() {
this.initializeSocket() this.initializeSocket()
this.checkVersion()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
} }
} }
</script> </script>
+7
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "0.9.6-beta", "version": "1.0.0",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+95
View File
@@ -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>
+2 -2
View File
@@ -1,5 +1,5 @@
<template> <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"> <div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
@@ -66,7 +66,7 @@ export default {
draggable draggable
}, },
async asyncData({ store, params, app, redirect, route }) { async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) { if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.path}`)
} }
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => { var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
+54 -8
View File
@@ -1,5 +1,5 @@
<template> <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="w-full h-full overflow-y-auto p-8">
<div class="flex max-w-6xl mx-auto"> <div class="flex max-w-6xl mx-auto">
<div class="w-52" style="min-width: 208px"> <div class="w-52" style="min-width: 208px">
@@ -10,19 +10,27 @@
</div> </div>
<div class="flex-grow px-10"> <div class="flex-grow px-10">
<div class="flex"> <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 class="flex-grow" />
</div> </div>
<p class="text-gray-300 text-sm my-1"> <p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> {{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p> </p>
<div class="flex items-center pt-4"> <div class="flex items-center pt-4">
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream"> <ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span> <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
Play {{ streaming ? 'Streaming' : 'Play' }}
</ui-btn> </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 :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' : ''"> <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="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> <p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
@@ -44,7 +52,9 @@
<p class="text-sm mb-2"> <p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span> Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p> </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> </div>
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" /> <tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
@@ -61,7 +71,7 @@
<script> <script>
export default { export default {
async asyncData({ store, params, app, redirect, route }) { async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) { if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`) return redirect(`/login?redirect=${route.path}`)
} }
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => { var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
@@ -82,6 +92,9 @@ export default {
} }
}, },
computed: { computed: {
isDeveloperMode() {
return this.$store.state.developerMode
},
missingPartChunks() { missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0] if (this.missingParts === 1) return this.missingParts[0]
var chunks = [] var chunks = []
@@ -128,6 +141,27 @@ export default {
author() { author() {
return this.book.author || 'Unknown' 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() { durationPretty() {
return this.audiobook.durationPretty return this.audiobook.durationPretty
}, },
@@ -158,7 +192,7 @@ export default {
return this.book.description || 'No Description' return this.book.description || 'No Description'
}, },
userAudiobooks() { userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {} return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
}, },
userAudiobook() { userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null return this.userAudiobooks[this.audiobookId] || null
@@ -180,6 +214,18 @@ export default {
} }
}, },
methods: { 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() { startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook) this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobook.id) this.$root.socket.emit('open_stream', this.audiobook.id)
+142
View File
@@ -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>
+196 -12
View File
@@ -1,18 +1,58 @@
<template> <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"> <div class="w-full max-w-4xl mx-auto">
<h1 class="text-2xl mb-2">Config</h1> <div class="flex items-center mb-2">
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <h1 class="text-2xl">Users</h1>
<div class="p-4 text-center h-20"> <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">
<p>Nothing much here yet...</p> <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>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4 mb-8"> <div class="p-4 text-center">
<p class="text-2xl">Scanner</p> <table id="accounts" class="mb-8">
<div class="flex-grow" /> <tr>
<ui-btn color="success" @click="scan">Scan</ui-btn> <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>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-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"> <div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p> <p class="font-mono">v{{ $config.version }}</p>
<div class="flex-grow" /> <div class="flex-grow" />
@@ -26,24 +66,168 @@
</a> </a>
</div> </div>
</div> </div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() { data() {
return {} return {
isResettingAudiobooks: false,
users: [],
showAccountModal: false,
isDeletingUser: false
}
}, },
computed: { computed: {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
},
isScanning() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
} }
}, },
methods: { methods: {
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() { scan() {
this.$root.socket.emit('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>
-3
View File
@@ -7,9 +7,6 @@
<script> <script>
export default { export default {
data() {
return {}
},
computed: { computed: {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
+4 -9
View File
@@ -34,29 +34,24 @@ export default {
watch: { watch: {
user(newVal) { user(newVal) {
if (newVal) { if (newVal) {
// if (process.env.NODE_ENV !== 'production') {
if (this.$route.query.redirect) { if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect) this.$router.replace(this.$route.query.redirect)
} else { } else {
this.$router.replace('/') this.$router.replace('/')
} }
// } else {
// window.location.reload()
// }
} }
} }
}, },
computed: { computed: {
user() { user() {
return this.$store.state.user return this.$store.state.user.user
} }
}, },
methods: { methods: {
async submitForm() { async submitForm() {
this.error = null this.error = null
this.processing = true this.processing = true
// var uri = `${process.env.serverUrl}/auth`
var payload = { var payload = {
username: this.username, username: this.username,
password: this.password || '' password: this.password || ''
@@ -71,7 +66,7 @@ export default {
} else if (authRes.error) { } else if (authRes.error) {
this.error = authRes.error this.error = authRes.error
} else { } else {
this.$store.commit('setUser', authRes.user) this.$store.commit('user/setUser', authRes.user)
} }
this.processing = false this.processing = false
}, },
@@ -90,7 +85,7 @@ export default {
} }
}) })
.then((res) => { .then((res) => {
this.$store.commit('setUser', res.user) this.$store.commit('user/setUser', res.user)
this.processing = false this.processing = false
}) })
.catch((error) => { .catch((error) => {
+1 -1
View File
@@ -4,7 +4,7 @@ export default function ({ $axios, store }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) { if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return 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) // console.log('Bearer token', bearerToken)
if (bearerToken) { if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}` config.headers.common['Authorization'] = `Bearer ${bearerToken}`
+86
View File
@@ -38,6 +38,92 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}` return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
} }
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) { function loadImageBlob(uri) {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = document.createElement('img') const img = document.createElement('img')
-1
View File
@@ -1,6 +1,5 @@
import Vue from "vue"; import Vue from "vue";
import Toast from "vue-toastification"; import Toast from "vue-toastification";
// Import the CSS or use your own!
import "vue-toastification/dist/index.css"; import "vue-toastification/dist/index.css";
const options = { const options = {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+65 -22
View File
@@ -1,4 +1,5 @@
import { sort } from '@/assets/fastSort' 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'] 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: [], audiobooks: [],
listeners: [], listeners: [],
genres: [...STANDARD_GENRES], genres: [...STANDARD_GENRES],
tags: [] tags: [],
series: []
}) })
export const getters = { export const getters = {
getFiltered: (state, getters, rootState) => () => { getFiltered: (state, getters, rootState) => () => {
var filtered = state.audiobooks var filtered = state.audiobooks
var settings = rootState.settings.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
var filterByParts = filterBy.split('.')
if (filterByParts.length > 1) { var searchGroups = ['genres', 'tags', 'series', 'authors']
var primary = filterByParts[0] var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
var secondary = filterByParts[1] if (group) {
if (primary === 'genres') { var filter = filterBy.replace(`${group}.`, '')
filtered = filtered.filter(ab => { if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
return ab.book && ab.book.genres.includes(secondary) 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 (primary === 'tags') { else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
filtered = filtered.filter(ab => ab.tags.includes(secondary))
}
} }
// TODO: Add filters
return filtered return filtered
}, },
getFilteredAndSorted: (state, getters, rootState) => () => { getFilteredAndSorted: (state, getters, rootState) => () => {
var settings = rootState.settings.settings var settings = rootState.user.settings
var direction = settings.orderDesc ? 'desc' : 'asc' var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered() var filtered = getters.getFiltered()
@@ -38,11 +37,19 @@ export const getters = {
// Supports dot notation strings i.e. "book.title" // Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab) 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 = { export const actions = {
load({ commit }) { load({ commit, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('audiobooks/load - User not set')
return
}
this.$axios this.$axios
.$get(`/api/audiobooks`) .$get(`/api/audiobooks`)
.then((data) => { .then((data) => {
@@ -65,6 +72,7 @@ export const mutations = {
genres = genres.concat(ab.book.genres) genres = genres.concat(ab.book.genres)
}) })
state.genres = [...new Set(genres)] // Remove Duplicates state.genres = [...new Set(genres)] // Remove Duplicates
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// TAGS // TAGS
var tags = [] var tags = []
@@ -72,6 +80,16 @@ export const mutations = {
tags = tags.concat(ab.tags) tags = tags.concat(ab.tags)
}) })
state.tags = [...new Set(tags)] // Remove Duplicates 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.audiobooks = audiobooks
state.listeners.forEach((listener) => { state.listeners.forEach((listener) => {
@@ -80,19 +98,34 @@ export const mutations = {
}, },
addUpdate(state, audiobook) { addUpdate(state, audiobook) {
var index = state.audiobooks.findIndex(a => a.id === audiobook.id) var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) { if (index >= 0) {
origAudiobook = { ...state.audiobooks[index] }
state.audiobooks.splice(index, 1, audiobook) state.audiobooks.splice(index, 1, audiobook)
} else { } else {
state.audiobooks.push(audiobook) state.audiobooks.push(audiobook)
} }
// GENRES
if (audiobook.book) { if (audiobook.book) {
// GENRES
var newGenres = [] var newGenres = []
audiobook.book.genres.forEach((genre) => { audiobook.book.genres.forEach((genre) => {
if (!state.genres.includes(genre)) newGenres.push(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 // TAGS
@@ -100,8 +133,10 @@ export const mutations = {
audiobook.tags.forEach((tag) => { audiobook.tags.forEach((tag) => {
if (!state.tags.includes(tag)) newTags.push(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) => { state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) { if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
@@ -112,8 +147,8 @@ export const mutations = {
remove(state, audiobook) { remove(state, audiobook) {
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id) state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
// GENRES
if (audiobook.book) { if (audiobook.book) {
// GENRES
audiobook.book.genres.forEach((genre) => { audiobook.book.genres.forEach((genre) => {
if (!STANDARD_GENRES.includes(genre)) { if (!STANDARD_GENRES.includes(genre)) {
var isInOtherAB = state.audiobooks.find(ab => { 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 // TAGS
@@ -138,7 +182,6 @@ export const mutations = {
} }
}) })
state.listeners.forEach((listener) => { state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) { if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
listener.meth() listener.meth()
+36
View File
@@ -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)
}
}
+37 -19
View File
@@ -1,34 +1,29 @@
import Vue from 'vue'
export const state = () => ({ export const state = () => ({
user: null,
streamAudiobook: null, streamAudiobook: null,
showEditModal: false, showEditModal: false,
selectedAudiobook: null, selectedAudiobook: null,
playOnLoad: false, playOnLoad: false,
isScanning: false, isScanning: false,
scanProgress: null isScanningCovers: false,
scanProgress: null,
coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false
}) })
export const getters = { export const getters = {
getToken: (state) => { getIsAudiobookSelected: state => audiobookId => {
return state.user ? state.user.token : null return !!state.selectedAudiobooks.includes(audiobookId)
}, },
getUserAudiobook: (state) => (audiobookId) => { getNumAudiobooksSelected: state => state.selectedAudiobooks.length
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
}
} }
export const actions = { export const actions = {}
}
export const mutations = { export const mutations = {
setUser(state, user) {
state.user = user
if (user.token) {
localStorage.setItem('token', user.token)
}
},
setStreamAudiobook(state, audiobook) { setStreamAudiobook(state, audiobook) {
state.playOnLoad = true state.playOnLoad = true
state.streamAudiobook = audiobook state.streamAudiobook = audiobook
@@ -56,8 +51,31 @@ export const mutations = {
setIsScanning(state, isScanning) { setIsScanning(state, isScanning) {
state.isScanning = isScanning state.isScanning = isScanning
}, },
setScanProgress(state, progress) { setScanProgress(state, scanProgress) {
if (progress > 0) state.isScanning = true if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = progress 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
} }
} }
-39
View File
@@ -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)
}
}
+85
View File
@@ -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 -3
View File
@@ -4,7 +4,8 @@ module.exports = {
purge: { purge: {
options: { options: {
safelist: [ safelist: [
'bg-success' 'bg-success',
'bg-red-600'
] ]
} }
}, },
@@ -16,13 +17,13 @@ module.exports = {
}, },
colors: { colors: {
bg: '#373838', bg: '#373838',
primary: '#262626', primary: '#232323',
accent: '#1ad691', accent: '#1ad691',
error: '#FF5252', error: '#FF5252',
info: '#2196F3', info: '#2196F3',
success: '#4CAF50', success: '#4CAF50',
successDark: '#3b8a3e',
warning: '#FB8C00', warning: '#FB8C00',
darkgreen: 'rgb(34,127,35)',
'black-50': '#bbbbbb', 'black-50': '#bbbbbb',
'black-100': '#666666', 'black-100': '#666666',
'black-200': '#555555', 'black-200': '#555555',
+5 -5
View File
@@ -7,12 +7,12 @@
<MyIP/> <MyIP/>
<Shell>sh</Shell> <Shell>sh</Shell>
<Privileged>false</Privileged> <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> <Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>Audiobook manager and player</Overview> <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 &amp; 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> <Category>MediaApp:Books MediaServer:Books Status:Beta</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI> <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> <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
<ExtraParams/> <ExtraParams/>
<PostArgs/> <PostArgs/>
@@ -20,7 +20,7 @@
<DateInstalled>1629238508</DateInstalled> <DateInstalled>1629238508</DateInstalled>
<DonateText/> <DonateText/>
<DonateLink/> <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 &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
<Networking> <Networking>
<Mode>bridge</Mode> <Mode>bridge</Mode>
<Publish> <Publish>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+1
View File
@@ -11,6 +11,7 @@ if (isDev) {
process.env.CONFIG_PATH = devEnv.ConfigPath process.env.CONFIG_PATH = devEnv.ConfigPath
process.env.METADATA_PATH = devEnv.MetadataPath process.env.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
} }
const PORT = process.env.PORT || 80 const PORT = process.env.PORT || 80
+86 -2223
View File
File diff suppressed because it is too large Load Diff
+8 -11
View File
@@ -1,32 +1,29 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "0.9.61-beta", "version": "1.0.0",
"description": "Self-hosted audiobook server for managing and playing audiobooks.", "description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev": "node index.js", "dev": "node index.js",
"start": "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"
}, },
"author": "advplyr", "author": "advplyr",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"axios": "^0.21.1", "axios": "^0.21.1",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"chokidar": "^3.5.2",
"cookie-parser": "^1.4.5", "cookie-parser": "^1.4.5",
"express": "^4.17.1", "express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0", "fs-extra": "^10.0.0",
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0", "libgen": "^2.1.0",
"njodb": "^0.4.20", "njodb": "^0.4.20",
"node-dir": "^0.1.17", "node-dir": "^0.1.17",
"socket.io": "^4.1.3" "podcast": "^1.3.0",
"socket.io": "^4.1.3",
"watcher": "^1.2.0"
}, },
"devDependencies": { "devDependencies": {}
"dotenv-cli": "^4.0.0", }
"release-it": "^14.11.5"
}
}
+10 -9
View File
@@ -2,9 +2,11 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks. 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: #### 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) * Auto add and update audiobooks (currently you need to press scan)
* Matching is all manual now and only using 1 source (openlibrary) * User permissions & editing users
* Support different views to see more details of each audiobook * 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 ## 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 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 ## Contributing
Feel free to help out Feel free to help out
+194 -11
View File
@@ -1,12 +1,16 @@
const express = require('express') const express = require('express')
const Logger = require('./Logger') const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject } = require('./utils/index')
class ApiController { class ApiController {
constructor(db, scanner, auth, streamManager, emitter) { constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) {
this.db = db this.db = db
this.scanner = scanner this.scanner = scanner
this.auth = auth this.auth = auth
this.streamManager = streamManager this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.emitter = emitter this.emitter = emitter
this.router = express() this.router = express()
@@ -17,8 +21,11 @@ class ApiController {
this.router.get('/find/covers', this.findCovers.bind(this)) this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this)) this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.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.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this)) this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.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.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.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.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.post('/authorize', this.authorize.bind(this))
this.router.get('/genres', this.getGenres.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) { find(req, res) {
@@ -39,7 +55,6 @@ class ApiController {
} }
findCovers(req, res) { findCovers(req, res) {
console.log('Find covers', req.query)
this.scanner.findCovers(req, res) this.scanner.findCovers(req, res)
} }
@@ -57,9 +72,22 @@ class ApiController {
} }
getAudiobooks(req, res) { getAudiobooks(req, res) {
Logger.info('Get Audiobooks') var audiobooks = []
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified()) if (req.query.q) {
res.json(audiobooksMinified) 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) { getAudiobook(req, res) {
@@ -68,10 +96,7 @@ class ApiController {
res.json(audiobook.toJSONExpanded()) res.json(audiobook.toJSONExpanded())
} }
async deleteAudiobook(req, res) { async handleDeleteAudiobook(audiobook) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
// Remove audiobook from users // Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) { for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i] var user = this.db.users[i]
@@ -94,12 +119,66 @@ class ApiController {
} }
} }
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id) 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) 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) { async updateAudiobookTracks(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id) var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404) if (!audiobook) return res.sendStatus(404)
@@ -142,6 +221,11 @@ class ApiController {
res.sendStatus(200) 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) { async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id) req.user.resetAudiobookProgress(req.params.id)
await this.db.updateEntity('user', req.user) await this.db.updateEntity('user', req.user)
@@ -149,6 +233,105 @@ class ApiController {
res.sendStatus(200) 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) { getGenres(req, res) {
res.json({ res.json({
genres: this.db.getGenres() genres: this.db.getGenres()
-211
View File
@@ -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
+32 -44
View File
@@ -2,7 +2,6 @@ const bcrypt = require('bcryptjs')
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
class Auth { class Auth {
constructor(db) { constructor(db) {
this.db = db this.db = db
@@ -42,7 +41,7 @@ class Auth {
const authHeader = req.headers['authorization'] const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1] const token = authHeader && authHeader.split(' ')[1]
if (token == null) { if (token == null) {
Logger.error('Api called without a token') Logger.error('Api called without a token', req.path)
return res.sendStatus(401) return res.sendStatus(401)
} }
@@ -75,6 +74,10 @@ class Auth {
verifyToken(token) { verifyToken(token) {
return new Promise((resolve) => { return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => { 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) var user = this.users.find(u => u.id === payload.userId)
resolve(user || null) resolve(user || null)
}) })
@@ -86,7 +89,7 @@ class Auth {
var password = req.body.password || '' var password = req.body.password || ''
Logger.debug('Check Auth', username, !!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) { if (!user) {
return res.json({ error: 'User not found' }) return res.json({ error: 'User not found' })
@@ -114,65 +117,50 @@ class Auth {
} }
} }
async checkAuth(req, res) { comparePassword(password, user) {
var username = req.body.username if (user.type === 'root' && !password && !user.pash) return true
Logger.debug('Check Auth', username, !!req.body.password) if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash)
}
var matchingUser = this.users.find(u => u.username === username) async userChangePassword(req, res) {
if (!matchingUser) { 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({ return res.json({
error: 'User not found' error: 'Invalid new password - Only root can have an empty password'
}) })
} }
var cleanedUser = { ...matchingUser } var compare = await this.comparePassword(password, matchingUser)
delete cleanedUser.pash if (!compare) {
return res.json({
// check for empty password (default) error: 'Invalid password'
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'
})
}
} }
// Set root password first time var pw = ''
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) { if (newPassword) {
console.log('Set root pash') pw = await this.hashPass(newPassword)
var pw = await this.hashPass(req.body.password)
if (!pw) { if (!pw) {
return res.json({ return res.json({
error: 'Hash failed' 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) matchingUser.pash = pw
if (compare) { var success = await this.db.updateEntity('user', matchingUser)
res.cookie('user', username, { signed: true }) if (success) {
res.json({ res.json({
user: cleanedUser success: true
}) })
} else { } else {
res.json({ res.json({
error: 'Invalid Password' error: 'Unknown error'
}) })
} }
} }
-85
View File
@@ -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
+67 -31
View File
@@ -12,7 +12,7 @@ class BookFinder {
async findByISBN(isbn) { async findByISBN(isbn) {
var book = await this.openLibrary.isbnLookup(isbn) var book = await this.openLibrary.isbnLookup(isbn)
if (book.errorCode) { if (book.errorCode) {
console.error('Book not found') Logger.error('Book not found')
} }
return book return book
} }
@@ -26,7 +26,17 @@ class BookFinder {
return title 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) { cleanTitleForCompares(title) {
if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
var stripped = this.stripSubtitle(title) var stripped = this.stripSubtitle(title)
@@ -35,71 +45,98 @@ class BookFinder {
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '') cleaned = cleaned.replace(/'/g, '')
cleaned = this.replaceAccentedChars(cleaned)
return cleaned.toLowerCase()
}
cleanAuthorForCompares(author) {
if (!author) return ''
var cleaned = this.replaceAccentedChars(author)
return cleaned.toLowerCase() return cleaned.toLowerCase()
} }
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title) var searchTitle = this.cleanTitleForCompares(title)
var searchAuthor = this.cleanAuthorForCompares(author)
return books.map(b => { return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title) b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title) b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
if (author) {
b.authorDistance = levenshteinDistance(b.author || '', author) // Total length of search (title or both title & author)
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
b.totalPossibleDistance = b.title.length b.totalPossibleDistance = b.title.length
if (b.cleanedTitle.includes(searchTitle) && searchTitle.length > 4) { if (author) {
b.includesSearch = searchTitle if (!b.author) {
} else if (b.title.includes(searchTitle) && searchTitle.length > 4) { b.authorDistance = author.length
b.includesSearch = searchTitle } 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 return b
}).filter(b => { }).filter(b => {
if (b.includesSearch) { // If search was found in result title exactly then skip over leven distance check if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
Logger.debug(`Exact search was found inside title ${b.cleanedTitle}/${b.includesSearch}`) Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
} else if (b.titleDistance > maxTitleDistance) { } else if (b.titleDistance > maxTitleDistance) {
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`) Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false return false
} }
if (author && b.authorDistance > maxAuthorDistance) { if (author) {
Logger.debug(`Filtering out search result "${b.title}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`) if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
return false 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 return true
}) })
} }
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) { async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title) 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) { if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`) Logger.error(`LibGen Search Error ${books.errorCode}`)
return [] return []
} }
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) { 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 return booksFiltered
} }
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) { async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title) 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) { if (books.errorCode) {
Logger.error(`OpenLib Search Error ${books.errorCode}`) Logger.error(`OpenLib Search Error ${books.errorCode}`)
return [] return []
} }
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) { 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 return booksFiltered
} }
@@ -108,7 +145,7 @@ class BookFinder {
var books = [] var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4 var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 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') { if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
@@ -119,18 +156,16 @@ class BookFinder {
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks) books = books.concat(lbBooks, olBooks)
} else { } else {
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = olBooks.find(b => (b.totalDistance < 4 && b.totalPossibleDistance > 4)) var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
if (hasCloseMatch) { if (!hasCloseMatch) {
books = olBooks Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
} else {
Logger.info(`Book Search, LibGen has no close matches - get openlib results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks) books = books.concat(lbBooks)
} }
if (!books.length && author) { if (!books.length && author && options.fallbackTitleOnly) {
Logger.info(`Book Search, no matches for title and author.. check title only`) Logger.debug(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options) return this.search(provider, title, null, options)
} }
} }
@@ -142,7 +177,8 @@ class BookFinder {
async findCovers(provider, title, author, options = {}) { async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(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 = [] var covers = []
searchResults.forEach((result) => { searchResults.forEach((result) => {
if (result.covers && result.covers.length) { if (result.covers && result.covers.length) {
+27 -3
View File
@@ -1,10 +1,9 @@
const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
const njodb = require("njodb") const njodb = require("njodb")
const jwt = require('jsonwebtoken') const jwt = require('jsonwebtoken')
const Logger = require('./Logger') const Logger = require('./Logger')
const Audiobook = require('./Audiobook') const Audiobook = require('./objects/Audiobook')
const User = require('./User') const User = require('./objects/User')
class Db { class Db {
constructor(CONFIG_PATH) { constructor(CONFIG_PATH) {
@@ -28,6 +27,12 @@ class Db {
return this.settingsDb return this.settingsDb
} }
getEntityDbKey(entityName) {
if (entityName === 'user') return 'usersDb'
else if (entityName === 'audiobook') return 'audiobooksDb'
return 'settingsDb'
}
getEntityArrayKey(entityName) { getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users' if (entityName === 'user') return 'users'
else if (entityName === 'audiobook') return 'audiobooks' else if (entityName === 'audiobook') return 'audiobooks'
@@ -52,6 +57,7 @@ class Db {
pash: '', pash: '',
stream: null, stream: null,
token, token,
isActive: true,
createdAt: Date.now() createdAt: Date.now()
}) })
} }
@@ -98,8 +104,10 @@ class Db {
updateAudiobook(audiobook) { updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => { return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
Logger.debug(`[DB] Audiobook updated ${results.updated}`) Logger.debug(`[DB] Audiobook updated ${results.updated}`)
return true
}).catch((error) => { }).catch((error) => {
Logger.error(`[DB] Audiobook update failed ${error}`) Logger.error(`[DB] Audiobook update failed ${error}`)
return false
}) })
} }
@@ -107,8 +115,10 @@ class Db {
return this.usersDb.insert([user]).then((results) => { return this.usersDb.insert([user]).then((results) => {
Logger.debug(`[DB] Inserted user ${results.inserted}`) Logger.debug(`[DB] Inserted user ${results.inserted}`)
this.users.push(user) this.users.push(user)
return true
}).catch((error) => { }).catch((error) => {
Logger.error(`[DB] Insert user Failed ${error}`) Logger.error(`[DB] Insert user Failed ${error}`)
return false
}) })
} }
@@ -137,8 +147,10 @@ class Db {
this[arrayKey] = this[arrayKey].map(e => { this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e return e.id === entity.id ? entity : e
}) })
return true
}).catch((error) => { }).catch((error) => {
Logger.error(`[DB] Update entity ${entityName} Failed: ${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() { getGenres() {
var allGenres = [] var allGenres = []
this.db.audiobooks.forEach((audiobook) => { this.db.audiobooks.forEach((audiobook) => {
+212
View File
@@ -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
+6
View File
@@ -30,6 +30,12 @@ class HlsController {
var streamId = req.params.stream var streamId = req.params.stream
var fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file) 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) var exists = await fs.pathExists(fullFilePath)
if (!exists) { if (!exists) {
Logger.warn('File path does not exist', fullFilePath) Logger.warn('File path does not exist', fullFilePath)
+5
View File
@@ -33,6 +33,11 @@ class Logger {
console.info(`[${this.timestamp}] INFO:`, ...args) console.info(`[${this.timestamp}] INFO:`, ...args)
} }
note(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
console.log(`[${this.timestamp}] NOTE:`, ...args)
}
warn(...args) { warn(...args) {
if (this.LogLevel > LOG_LEVEL.WARN) return if (this.LogLevel > LOG_LEVEL.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args) console.warn(`[${this.timestamp}] WARN:`, ...args)
+54
View File
@@ -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
+229 -19
View File
@@ -1,8 +1,9 @@
const Logger = require('./Logger') const Logger = require('./Logger')
const BookFinder = require('./BookFinder') const BookFinder = require('./BookFinder')
const Audiobook = require('./Audiobook') const Audiobook = require('./objects/Audiobook')
const audioFileScanner = require('./utils/audioFileScanner') const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir') const { getAllAudiobookFiles } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils') const { secondsToTimestamp } = require('./utils/fileUtils')
class Scanner { class Scanner {
@@ -12,6 +13,8 @@ class Scanner {
this.db = db this.db = db
this.emitter = emitter this.emitter = emitter
this.cancelScan = false
this.bookFinder = new BookFinder() this.bookFinder = new BookFinder()
} }
@@ -19,42 +22,204 @@ class Scanner {
return this.db.audiobooks 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() { 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() const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath) 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++) { for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i] var audiobookData = audiobookDataFound[i]
if (!audiobookData.parts.length) { var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.error('No Valid Parts for Audiobook', audiobookData) Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
} else {
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath) if (existingAudiobook) {
if (existingAudiobook) { if (!audiobookData.audioFiles.length) {
Logger.info('Audiobook already added', audiobookData.title) Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
// Todo: Update Audiobook here
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 { } else {
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
var audiobook = new Audiobook() var audiobook = new Audiobook()
audiobook.setData(audiobookData) audiobook.setData(audiobookData)
await audioFileScanner.scanParts(audiobook, audiobookData.parts) await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) { if (!audiobook.tracks.length) {
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title) Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
} else { } 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) await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified()) this.emitter('audiobook_added', audiobook.toJSONMinified())
scanResults.added++
} }
} } // end if add new
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) }
this.emitter('scan_progress', { var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
scanType: 'files',
progress: {
total: audiobookDataFound.length, total: audiobookDataFound.length,
done: i + 1, done: i + 1,
progress progress
}) }
})
if (this.cancelScan) {
this.cancelScan = false
break
} }
} }
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000) 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) { async fetchMetadata(id, trackIndex = 0) {
@@ -70,6 +235,48 @@ class Scanner {
return scanResult 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) { async find(req, res) {
var method = req.params.method var method = req.params.method
var query = req.query var query = req.query
@@ -87,7 +294,10 @@ class Scanner {
async findCovers(req, res) { async findCovers(req, res) {
var query = req.query 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) res.json(result)
} }
} }
+62 -27
View File
@@ -3,7 +3,6 @@ const express = require('express')
const http = require('http') const http = require('http')
const SocketIO = require('socket.io') const SocketIO = require('socket.io')
const fs = require('fs-extra') const fs = require('fs-extra')
const cookieparser = require('cookie-parser')
const Auth = require('./Auth') const Auth = require('./Auth')
const Watcher = require('./Watcher') const Watcher = require('./Watcher')
@@ -12,15 +11,17 @@ const Db = require('./Db')
const ApiController = require('./ApiController') const ApiController = require('./ApiController')
const HlsController = require('./HlsController') const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager') const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const Logger = require('./Logger') const Logger = require('./Logger')
class Server { class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
this.Port = PORT this.Port = PORT
this.Host = '0.0.0.0' this.Host = '0.0.0.0'
this.ConfigPath = CONFIG_PATH this.ConfigPath = Path.normalize(CONFIG_PATH)
this.AudiobookPath = AUDIOBOOK_PATH this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
this.MetadataPath = METADATA_PATH this.MetadataPath = Path.normalize(METADATA_PATH)
fs.ensureDirSync(CONFIG_PATH) fs.ensureDirSync(CONFIG_PATH)
fs.ensureDirSync(METADATA_PATH) fs.ensureDirSync(METADATA_PATH)
@@ -31,7 +32,9 @@ class Server {
this.watcher = new Watcher(this.AudiobookPath) this.watcher = new Watcher(this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this)) this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath) 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.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
this.server = null this.server = null
@@ -40,6 +43,7 @@ class Server {
this.clients = {} this.clients = {}
this.isScanning = false this.isScanning = false
this.isScanningCovers = false
this.isInitialized = false this.isInitialized = false
} }
@@ -51,34 +55,43 @@ class Server {
} }
emitter(ev, data) { emitter(ev, data) {
Logger.debug('EMITTER', ev) // Logger.debug('EMITTER', ev)
if (!this.io) {
Logger.error('Invalid IO')
return
}
this.io.emit(ev, data) this.io.emit(ev, data)
} }
async fileAddedUpdated({ path, fullPath }) { async fileAddedUpdated({ path, fullPath }) { }
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
}
async fileRemoved({ path, fullPath }) { } async fileRemoved({ path, fullPath }) { }
async scan() { async scan() {
Logger.info('[SERVER] Starting Scan') Logger.info('[Server] Starting Scan')
this.isScanning = true this.isScanning = true
this.isInitialized = true this.isInitialized = true
this.emitter('scan_start') this.emitter('scan_start', 'files')
await this.scanner.scan() var results = await this.scanner.scan()
this.isScanning = false this.isScanning = false
this.emitter('scan_complete') this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[SERVER] Scan complete') 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() { async init() {
Logger.info('[SERVER] Init') Logger.info('[Server] Init')
await this.streamManager.removeOrphanStreams() await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
await this.db.init() await this.db.init()
this.auth.init() this.auth.init()
@@ -101,25 +114,29 @@ class Server {
this.server = http.createServer(app) this.server = http.createServer(app)
app.use(cookieparser('secret_family_recipe'))
app.use(this.auth.cors) app.use(this.auth.cors)
// Static path to generated nuxt // Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath)) 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(this.MetadataPath))
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(express.json()) 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('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router) app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// app.use('/hls', this.hlsController.router)
app.get('/', (req, res) => { app.use('/feeds', this.rssFeeds.router)
res.sendFile('/index.html')
})
app.post('/login', (req, res) => this.auth.login(req, res)) app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this)) app.post('/logout', this.logout.bind(this))
@@ -128,6 +145,21 @@ class Server {
res.json({ success: true }) 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, () => { this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`) 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('auth', (token) => this.authenticateSocket(socket, token))
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('test', () => { socket.on('test', () => {
socket.emit('test_received', socket.id) socket.emit('test_received', socket.id)
}) })
+21 -2
View File
@@ -1,4 +1,5 @@
const Stream = require('./Stream') const Stream = require('./objects/Stream')
const StreamTest = require('./test/StreamTest')
const Logger = require('./Logger') const Logger = require('./Logger')
const fs = require('fs-extra') const fs = require('fs-extra')
const Path = require('path') const Path = require('path')
@@ -90,7 +91,8 @@ class StreamManager {
Logger.info('Close Stream Request', socket.id) Logger.info('Close Stream Request', socket.id)
var client = socket.sheepClient var client = socket.sheepClient
if (!client || !client.stream) { 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 return
} }
// var streamId = client.stream.id // var streamId = client.stream.id
@@ -100,6 +102,23 @@ class StreamManager {
this.db.updateUserStream(client.user.id, null) 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 }) { streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient var client = socket.sheepClient
if (!client || !client.stream) { if (!client || !client.stream) {
+19 -14
View File
@@ -1,6 +1,6 @@
var EventEmitter = require('events') var EventEmitter = require('events')
var Logger = require('./Logger') var Logger = require('./Logger')
var chokidar = require('chokidar') var Watcher = require('watcher')
class FolderWatcher extends EventEmitter { class FolderWatcher extends EventEmitter {
constructor(audiobookPath) { constructor(audiobookPath) {
@@ -12,15 +12,14 @@ class FolderWatcher extends EventEmitter {
initWatcher() { initWatcher() {
try { try {
Logger.info('[WATCHER] Initializing..') Logger.info('[FolderWatcher] Initializing..')
this.watcher = chokidar.watch(this.AudiobookPath, { this.watcher = new Watcher(this.AudiobookPath, {
ignoreInitial: true,
ignored: /(^|[\/\\])\../, // ignore dotfiles ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true, renameDetection: true,
awaitWriteFinish: { renameTimeout: 2000,
stabilityThreshold: 2500, recursive: true,
pollInterval: 500 ignoreInitial: true,
} persistent: true
}) })
this.watcher this.watcher
.on('add', (path) => { .on('add', (path) => {
@@ -29,10 +28,12 @@ class FolderWatcher extends EventEmitter {
this.onFileUpdated(path) this.onFileUpdated(path)
}).on('unlink', path => { }).on('unlink', path => {
this.onFileRemoved(path) this.onFileRemoved(path)
}).on('rename', (path, pathNext) => {
this.onRename(path, pathNext)
}).on('error', (error) => { }).on('error', (error) => {
Logger.error(`Watcher error: ${error}`) Logger.error(`[FolderWatcher] ${error}`)
}).on('ready', () => { }).on('ready', () => {
Logger.info('[WATCHER] Ready') Logger.info('[FolderWatcher] Ready')
}) })
} catch (error) { } catch (error) {
Logger.error('Chokidar watcher failed', error) Logger.error('Chokidar watcher failed', error)
@@ -45,7 +46,7 @@ class FolderWatcher extends EventEmitter {
} }
onNewFile(path) { onNewFile(path) {
Logger.info('FolderWatcher: New File', path) Logger.debug('FolderWatcher: New File', path)
this.emit('file_added', { this.emit('file_added', {
path: path.replace(this.AudiobookPath, ''), path: path.replace(this.AudiobookPath, ''),
fullPath: path fullPath: path
@@ -53,7 +54,7 @@ class FolderWatcher extends EventEmitter {
} }
onFileRemoved(path) { onFileRemoved(path) {
Logger.info('FolderWatcher: File Removed', path) Logger.debug('[FolderWatcher] File Removed', path)
this.emit('file_removed', { this.emit('file_removed', {
path: path.replace(this.AudiobookPath, ''), path: path.replace(this.AudiobookPath, ''),
fullPath: path fullPath: path
@@ -61,11 +62,15 @@ class FolderWatcher extends EventEmitter {
} }
onFileUpdated(path) { onFileUpdated(path) {
Logger.info('FolderWatcher: Updated File', path) Logger.debug('[FolderWatcher] Updated File', path)
this.emit('file_updated', { this.emit('file_updated', {
path: path.replace(this.AudiobookPath, ''), path: path.replace(this.AudiobookPath, ''),
fullPath: path fullPath: path
}) })
} }
onRename(pathFrom, pathTo) {
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
}
} }
module.exports = FolderWatcher module.exports = FolderWatcher
+146
View File
@@ -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 { class AudioTrack {
constructor(audioTrack = null) { constructor(audioTrack = null) {
this.index = null this.index = null
this.ino = null
this.path = null this.path = null
this.fullPath = null this.fullPath = null
this.ext = null this.ext = null
@@ -31,6 +33,8 @@ class AudioTrack {
construct(audioTrack) { construct(audioTrack) {
this.index = audioTrack.index this.index = audioTrack.index
this.ino = audioTrack.ino || null
this.path = audioTrack.path this.path = audioTrack.path
this.fullPath = audioTrack.fullPath this.fullPath = audioTrack.fullPath
this.ext = audioTrack.ext this.ext = audioTrack.ext
@@ -45,6 +49,12 @@ class AudioTrack {
this.timeBase = audioTrack.timeBase this.timeBase = audioTrack.timeBase
this.channels = audioTrack.channels this.channels = audioTrack.channels
this.channelLayout = audioTrack.channelLayout 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() { get name() {
@@ -54,6 +64,7 @@ class AudioTrack {
toJSON() { toJSON() {
return { return {
index: this.index, index: this.index,
ino: this.ino,
path: this.path, path: this.path,
fullPath: this.fullPath, fullPath: this.fullPath,
ext: this.ext, ext: this.ext,
@@ -65,12 +76,19 @@ class AudioTrack {
language: this.language, language: this.language,
timeBase: this.timeBase, timeBase: this.timeBase,
channels: this.channels, 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) { setData(probeData) {
this.index = probeData.index this.index = probeData.index
this.ino = probeData.ino || null
this.path = probeData.path this.path = probeData.path
this.fullPath = probeData.fullPath this.fullPath = probeData.fullPath
this.ext = probeData.ext this.ext = probeData.ext
@@ -92,5 +110,17 @@ class AudioTrack {
this.tagTitle = probeData.file_tag_title || null this.tagTitle = probeData.file_tag_title || null
this.tagTrack = probeData.file_tag_track || 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 module.exports = AudioTrack
+394
View File
@@ -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
+48
View File
@@ -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
+157
View File
@@ -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
+107
View File
@@ -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
+7 -24
View File
@@ -2,9 +2,10 @@ const Ffmpeg = require('fluent-ffmpeg')
const EventEmitter = require('events') const EventEmitter = require('events')
const Path = require('path') const Path = require('path')
const fs = require('fs-extra') const fs = require('fs-extra')
const Logger = require('./Logger') const Logger = require('../Logger')
const { secondsToTimestamp } = require('./utils/fileUtils') const { secondsToTimestamp } = require('../utils/fileUtils')
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator') const { writeConcatFile } = require('../utils/ffmpegHelpers')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
class Stream extends EventEmitter { class Stream extends EventEmitter {
constructor(streamPath, client, audiobook) { constructor(streamPath, client, audiobook) {
@@ -19,7 +20,7 @@ class Stream extends EventEmitter {
this.streamPath = Path.join(streamPath, this.id) this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt') this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8') 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.startTime = 0
this.ffmpeg = null this.ffmpeg = null
@@ -129,7 +130,6 @@ class Stream extends EventEmitter {
async generatePlaylist() { async generatePlaylist() {
fs.ensureDirSync(this.streamPath) fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength) await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
console.log('Playlist generated')
return this.clientPlaylistUri return this.clientPlaylistUri
} }
@@ -212,29 +212,12 @@ class Stream extends EventEmitter {
}, 2000) }, 2000)
} }
escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
async start() { async start() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`) Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
this.ffmpeg = Ffmpeg() 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 trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
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)
this.ffmpeg.addInput(this.concatFilesPath) this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat') this.ffmpeg.inputFormat('concat')
@@ -267,7 +250,7 @@ class Stream extends EventEmitter {
]) ])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename) var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`) this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.fakePlaylistPath) this.ffmpeg.output(this.finalPlaylistPath)
this.ffmpeg.on('start', (command) => { this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command) Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
+48 -4
View File
@@ -6,14 +6,26 @@ class User {
this.type = null this.type = null
this.stream = null this.stream = null
this.token = null this.token = null
this.isActive = true
this.createdAt = null this.createdAt = null
this.audiobooks = null this.audiobooks = null
this.settings = {}
if (user) { if (user) {
this.construct(user) this.construct(user)
} }
} }
getDefaultUserSettings() {
return {
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120
}
}
toJSON() { toJSON() {
return { return {
id: this.id, id: this.id,
@@ -23,7 +35,9 @@ class User {
stream: this.stream, stream: this.stream,
token: this.token, token: this.token,
audiobooks: this.audiobooks, audiobooks: this.audiobooks,
createdAt: this.createdAt isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
} }
} }
@@ -35,7 +49,9 @@ class User {
stream: this.stream, stream: this.stream,
token: this.token, token: this.token,
audiobooks: this.audiobooks, 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.username = user.username
this.pash = user.pash this.pash = user.pash
this.type = user.type this.type = user.type
this.stream = user.stream this.stream = user.stream || null
this.token = user.token this.token = user.token
this.audiobooks = user.audiobooks || null 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) { updateAudiobookProgress(stream) {
@@ -64,6 +82,32 @@ class User {
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime 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) { resetAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) { if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false return false
+6
View File
@@ -26,6 +26,12 @@ class OpenLibrary {
async getWorksData(worksKey) { async getWorksData(worksKey) {
var worksData = await this.get(`${worksKey}.json`) var worksData = await this.get(`${worksKey}.json`)
if (!worksData) {
return {
errorMsg: 'Works Data Request failed',
errorCode: 500
}
}
if (!worksData.covers) worksData.covers = [] 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 coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
var description = null var description = null
+31 -45
View File
@@ -1,7 +1,6 @@
const Path = require('path') const Path = require('path')
const Logger = require('../Logger') const Logger = require('../Logger')
var prober = require('./prober') const prober = require('./prober')
function getDefaultAudioStream(audioStreams) { function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0] if (audioStreams.length === 1) return audioStreams[0]
@@ -76,60 +75,61 @@ function getTrackNumberFromFilename(filename) {
return number return number
} }
async function scanParts(audiobook, parts) { async function scanAudioFiles(audiobook, newAudioFiles) {
if (!parts || !parts.length) { if (!newAudioFiles || !newAudioFiles.length) {
Logger.error('Scan Parts', audiobook.title, 'No Parts', parts) Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
return return
} }
var tracks = [] var tracks = []
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < newAudioFiles.length; i++) {
var fullPath = Path.join(audiobook.fullPath, parts[i]) var audioFile = newAudioFiles[i]
var scanData = await scan(fullPath) var scanData = await scan(audioFile.fullPath)
if (!scanData || scanData.error) { if (!scanData || scanData.error) {
Logger.error('Scan failed for', parts[i]) Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
audiobook.invalidParts.push(parts[i]) // audiobook.invalidAudioFiles.push(parts[i])
continue; continue;
} }
var trackNumFromMeta = getTrackNumberFromMeta(scanData) var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var trackNumFromFilename = getTrackNumberFromFilename(parts[i]) var trackNumFromFilename = getTrackNumberFromFilename(audioFile.filename)
var audioFileObj = { var audioFileObj = {
path: Path.join(audiobook.path, parts[i]), ino: audioFile.ino,
ext: Path.extname(parts[i]), filename: audioFile.filename,
filename: parts[i], path: audioFile.path,
fullPath: fullPath, fullPath: audioFile.fullPath,
ext: audioFile.ext,
...scanData, ...scanData,
trackNumFromMeta, trackNumFromMeta,
trackNumFromFilename trackNumFromFilename
} }
audiobook.audioFiles.push(audioFileObj) var audioFile = audiobook.addAudioFile(audioFileObj)
var trackNumber = 1 var trackNumber = 1
if (parts.length > 1) { if (newAudioFiles.length > 1) {
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
if (trackNumber === null) { if (trackNumber === null) {
Logger.error('Invalid track number for', parts[i]) Logger.error('[AudioFileScanner] Invalid track number for', audioFile.filename)
audioFileObj.invalid = true audioFile.invalid = true
audioFileObj.error = 'Failed to get track number' audioFile.error = 'Failed to get track number'
continue; continue;
} }
} }
if (tracks.find(t => t.index === trackNumber)) { if (tracks.find(t => t.index === trackNumber)) {
Logger.error('Duplicate track number for', parts[i]) Logger.error('[AudioFileScanner] Duplicate track number for', audioFile.filename)
audioFileObj.invalid = true audioFile.invalid = true
audioFileObj.error = 'Duplicate track number' audioFile.error = 'Duplicate track number'
continue; continue;
} }
audioFileObj.index = trackNumber audioFile.index = trackNumber
tracks.push(audioFileObj) tracks.push(audioFile)
} }
if (!tracks.length) { if (!tracks.length) {
Logger.warn('No Tracks for audiobook', audiobook.id) Logger.warn('[AudioFileScanner] No Tracks for audiobook', audiobook.id)
return return
} }
@@ -148,26 +148,12 @@ async function scanParts(audiobook, parts) {
}) })
} }
var parts_copy = tracks.map(p => ({ ...p })) var hasTracksAlready = audiobook.tracks.length
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)
}
tracks.forEach((track) => { tracks.forEach((track) => {
audiobook.addTrack(track) audiobook.addTrack(track)
}) })
if (hasTracksAlready) {
audiobook.tracks.sort((a, b) => a.index - b.index)
}
} }
module.exports.scanParts = scanParts module.exports.scanAudioFiles = scanAudioFiles
+68
View File
@@ -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()
+37
View File
@@ -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
+7
View File
@@ -17,6 +17,13 @@ async function getFileStat(path) {
} }
module.exports.getFileStat = getFileStat 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) { function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) { if (bytes === 0) {
return '0 Bytes' return '0 Bytes'
+37 -1
View File
@@ -1,3 +1,7 @@
const Path = require('path')
const fs = require('fs')
const Logger = require('../Logger')
const levenshteinDistance = (str1, str2, caseSensitive = false) => { const levenshteinDistance = (str1, str2, caseSensitive = false) => {
if (!caseSensitive) { if (!caseSensitive) {
str1 = str1.toLowerCase() str1 = str1.toLowerCase()
@@ -23,4 +27,36 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
} }
return track[str2.length][str1.length]; 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
})
}
+67
View File
@@ -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
}
}
+346
View File
@@ -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];
};
+23 -22
View File
@@ -1,8 +1,9 @@
const Path = require('path') const Path = require('path')
const dir = require('node-dir') const dir = require('node-dir')
const Logger = require('../Logger') 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 INFO_FORMATS = ['nfo']
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp'] const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
const EBOOK_FORMATS = ['epub', 'pdf'] const EBOOK_FORMATS = ['epub', 'pdf']
@@ -22,7 +23,7 @@ function getPaths(path) {
function getFileType(ext) { function getFileType(ext) {
var ext_cleaned = ext.toLowerCase() var ext_cleaned = ext.toLowerCase()
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1) 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 (INFO_FORMATS.includes(ext_cleaned)) return 'info'
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image' if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook' if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
@@ -34,17 +35,19 @@ async function getAllAudiobookFiles(abRootPath) {
var audiobooks = {} var audiobooks = {}
paths.files.forEach((filepath) => { 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 pathformat = Path.parse(relpath)
var path = pathformat.dir var path = pathformat.dir
// If relative file directory has 3 folders, then the middle folder will be series if (!path) {
var splitDir = pathformat.dir.split(Path.sep) Logger.error('Ignoring file in root dir', filepath)
if (splitDir.length === 1) {
Logger.error('Invalid file in root dir', filepath)
return 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 var series = null
if (splitDir.length > 1) series = splitDir.shift() if (splitDir.length > 1) series = splitDir.shift()
var title = splitDir.shift() var title = splitDir.shift()
@@ -64,26 +67,24 @@ async function getAllAudiobookFiles(abRootPath) {
audiobooks[path] = { audiobooks[path] = {
author: author, author: author,
title: title, title: title,
series: series, series: cleanString(series),
publishYear: publishYear, publishYear: publishYear,
path: relpath, path: path,
fullPath: Path.join(abRootPath, path), fullPath: Path.join(abRootPath, path),
parts: [], audioFiles: [],
otherFiles: [] otherFiles: []
} }
} }
var fileObj = {
var filetype = getFileType(pathformat.ext) filetype: getFileType(pathformat.ext),
if (filetype === 'abpart') { filename: pathformat.base,
audiobooks[path].parts.push(pathformat.base) path: relpath,
fullPath: filepath,
ext: pathformat.ext
}
if (fileObj.filetype === 'audio') {
audiobooks[path].audioFiles.push(fileObj)
} else { } else {
var fileObj = {
filetype: filetype,
filename: pathformat.base,
path: relpath,
fullPath: filepath,
ext: pathformat.ext
}
audiobooks[path].otherFiles.push(fileObj) audiobooks[path].otherFiles.push(fileObj)
} }
}) })
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB