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
/metadata
dev.js
/test/
/client/.nuxt/
test/
/client/.nuxt/
/client/dist/
+3 -2
View File
@@ -5,5 +5,6 @@ node_modules/
/audiobooks/
/audiobooks2/
/metadata/
/test/
/client/.nuxt/
test/
/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-right: 6px solid transparent;
border-top: 6px solid white;
}
.triangle-right {
width: 0;
height: 0;
border-left: 8px solid transparent;
border-bottom: 8px solid transparent;
border-top: 8px solid rgb(34,127,35);
border-right: 8px solid rgb(34,127,35);
}
.icon-text {
font-size: 1.1rem;
}
#page-wrapper {
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
}
.box-shadow-md {
box-shadow: 2px 8px 6px #111111aa;
}
.box-shadow-lg-up {
box-shadow: 0px -12px 8px #111111ee;
}
.box-shadow-xl {
box-shadow: 2px 14px 8px #111111aa;
}
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
+27 -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">
<span class="material-icons text-3xl">forward_10</span>
</div>
<controls-playback-speed-control v-model="playbackRate" @change="updatePlaybackRate" />
<controls-playback-speed-control v-model="playbackRate" @change="playbackRateChanged" />
</template>
<template v-else>
<div class="cursor-pointer p-2 shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-8 animate-spin">
@@ -89,7 +89,7 @@ export default {
},
computed: {
token() {
return this.$store.getters.getToken
return this.$store.getters['user/getToken']
},
totalDurationPretty() {
return this.$secondsToTimestamp(this.totalDuration)
@@ -130,12 +130,22 @@ export default {
},
updatePlaybackRate(playbackRate) {
if (this.audioEl) {
console.log('UpdatePlaybackRate', playbackRate)
this.audioEl.playbackRate = playbackRate
try {
this.audioEl.playbackRate = playbackRate
this.audioEl.defaultPlaybackRate = playbackRate
} catch (error) {
console.error('Update playback rate failed', error)
}
} else {
console.error('No Audio El updatePlaybackRate')
}
},
playbackRateChanged(playbackRate) {
this.updatePlaybackRate(playbackRate)
this.$store.dispatch('user/updateUserSettings', { playbackRate }).catch((err) => {
console.error('Failed to update settings', err)
})
},
mousemoveTrack(e) {
var offsetX = e.offsetX
var time = (offsetX / this.trackWidth) * this.totalDuration
@@ -355,7 +365,8 @@ export default {
this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio
audio.volume = this.volume
audio.playbackRate = this.playbackRate
audio.defaultPlaybackRate = this.playbackRate
this.hlsInstance.attachMedia(audio)
this.hlsInstance.on(Hls.Events.MEDIA_ATTACHED, () => {
// console.log('[HLS] MEDIA ATTACHED')
@@ -410,17 +421,27 @@ export default {
this.set(this.url, startTime, true)
},
init() {
this.playbackRate = this.$store.getters['user/getUserSetting']('playbackRate') || 1
this.audioEl = this.$refs.audio
if (this.$refs.track) {
this.trackWidth = this.$refs.track.clientWidth
} else {
console.error('Track not loaded', this.$refs)
}
},
settingsUpdated(settings) {
if (settings.playbackRate && this.playbackRate !== settings.playbackRate) {
this.updatePlaybackRate(settings.playbackRate)
}
}
},
mounted() {
// this.$nextTick(this.init)
this.$store.commit('user/addSettingsListener', { id: 'audioplayer', meth: this.settingsUpdated })
this.init()
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'audioplayer')
}
}
</script>
+74 -17
View File
@@ -2,21 +2,31 @@
<div class="w-full h-16 bg-primary relative">
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-6 py-1 z-30">
<div class="flex h-full items-center">
<img v-if="!showBack" src="/LogoTransparent.png" class="w-12 h-12 mr-4" />
<img v-if="!showBack" src="/Logo48.png" class="w-12 h-12 mr-4" />
<a v-if="showBack" @click="back" class="rounded-full h-12 w-12 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-4 cursor-pointer">
<span class="material-icons text-4xl text-white">arrow_back</span>
</a>
<h1 class="text-2xl font-book">AudioBookshelf</h1>
<h1 class="text-2xl font-book mr-6">AudioBookshelf</h1>
<controls-global-search />
<div class="flex-grow" />
<!-- <button class="px-4 py-2 bg-blue-500 rounded-xs" @click="scan">Scan</button> -->
<nuxt-link to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<nuxt-link v-if="isRootUser" to="/config" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
<span class="material-icons">settings</span>
</nuxt-link>
<ui-menu :label="username" :items="menuItems" @action="menuAction" class="ml-5" />
</div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
<h1 class="text-2xl px-4">{{ numAudiobooksSelected }} Selected</h1>
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
<div class="flex-grow" />
<ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2" @click="batchEditClick"><span class="material-icons text-gray-200 pt-1">edit</span></ui-btn>
<ui-btn color="error" small class="mx-2" :loading="processingBatchDelete" @click="batchDeleteClick"><span class="material-icons text-gray-200 pt-1">delete</span></ui-btn>
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
</div>
</div>
</div>
</template>
@@ -26,15 +36,17 @@ export default {
data() {
return {
menuItems: [
// {
// value: 'settings',
// text: 'Settings'
// },
{
value: 'account',
text: 'Account',
to: '/account'
},
{
value: 'logout',
text: 'Logout'
}
]
],
processingBatchDelete: false
}
},
computed: {
@@ -42,10 +54,25 @@ export default {
return this.$route.name !== 'index'
},
user() {
return this.$store.state.user
return this.$store.state.user.user
},
isRootUser() {
return this.$store.getters['user/getIsRoot']
},
username() {
return this.user ? this.user.username : 'err'
},
numAudiobooksSelected() {
return this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
isAllSelected() {
return this.audiobooksShowing.length === this.selectedAudiobooks.length
},
audiobooksShowing() {
return this.$store.getters['audiobooks/getFiltered']()
}
},
methods: {
@@ -56,10 +83,6 @@ export default {
this.$router.push('/')
}
},
scan() {
console.log('Call Start Init')
this.$root.socket.emit('scan')
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
@@ -72,9 +95,44 @@ export default {
menuAction(action) {
if (action === 'logout') {
this.logout()
} else if (action === 'settings') {
// Show settings modal
}
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
},
toggleSelectAll() {
if (this.isAllSelected) {
this.cancelSelectionMode()
} else {
var audiobookIds = this.audiobooksShowing.map((a) => a.id)
this.$store.commit('setSelectedAudiobooks', audiobookIds)
}
},
batchDeleteClick() {
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
.$post(`/api/audiobooks/delete`, {
audiobookIds: this.selectedAudiobooks
})
.then(() => {
this.$toast.success('Batch delete success!')
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
})
.catch((error) => {
this.$toast.error('Batch delete failed')
console.error('Failed to batch delete', error)
this.processingBatchDelete = false
this.$store.commit('setProcessingBatch', false)
})
}
},
batchEditClick() {
this.$router.push('/batch')
}
},
mounted() {}
@@ -83,7 +141,6 @@ export default {
<style>
#appbar {
/* box-shadow: 0px 8px 8px #111111aa; */
box-shadow: 0px 5px 5px #11111155;
}
</style>
+56 -10
View File
@@ -1,5 +1,14 @@
<template>
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto">
<div id="bookshelf" ref="wrapper" class="w-full h-full overflow-y-auto relative">
<!-- Cover size widget -->
<div v-show="!isSelectionMode" class="fixed bottom-2 right-4 z-20">
<div class="rounded-full py-1 bg-primary px-2 border border-black-100 text-center flex items-center box-shadow-md" @mousedown.prevent @mouseup.prevent>
<span class="material-icons" :class="selectedSizeIndex === 0 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="decreaseSize">remove</span>
<p class="px-2 font-mono">{{ bookCoverWidth }}</p>
<span class="material-icons" :class="selectedSizeIndex === availableSizes.length - 1 ? 'text-gray-400' : 'hover:text-yellow-300 cursor-pointer'" style="font-size: 0.9rem" @mousedown.prevent @click="increaseSize">add</span>
</div>
</div>
<div v-if="!audiobooks.length" class="w-full flex flex-col items-center justify-center py-12">
<p class="text-center text-2xl font-book mb-4">Your Audiobookshelf is empty!</p>
<ui-btn color="success" @click="scan">Scan your Audiobooks</ui-btn>
@@ -9,7 +18,7 @@
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
<template v-for="audiobook in shelf">
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
<cards-book-card :ref="`audiobookCard-${audiobook.id}`" :key="audiobook.id" :width="bookCoverWidth" :user-progress="userAudiobooks[audiobook.id]" :audiobook="audiobook" />
</template>
</div>
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
@@ -24,24 +33,51 @@ export default {
data() {
return {
width: 0,
bookWidth: 176,
booksPerRow: 0,
groupedBooks: [],
currFilterOrderKey: null
currFilterOrderKey: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40
}
},
computed: {
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
filterOrderKey() {
return this.$store.getters['settings/getFilterOrderKey']
return this.$store.getters['user/getFilterOrderKey']
},
bookCoverWidth() {
return this.availableSizes[this.selectedSizeIndex]
},
sizeMultiplier() {
return this.bookCoverWidth / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
bookWidth() {
return this.bookCoverWidth + this.paddingX * 2
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
}
},
methods: {
increaseSize() {
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
decreaseSize() {
this.selectedSizeIndex = Math.max(0, this.selectedSizeIndex - 1)
this.resize()
this.$store.dispatch('user/updateUserSettings', { bookshelfCoverSize: this.bookCoverWidth })
},
setGroupedBooks() {
var groups = []
var currentRow = 0
@@ -66,6 +102,7 @@ export default {
},
calculateBookshelf() {
this.width = this.$refs.wrapper.clientWidth
this.width = Math.max(0, this.width - this.rowPaddingX * 2)
var booksPerRow = Math.floor(this.width / this.bookWidth)
this.booksPerRow = booksPerRow
},
@@ -76,6 +113,9 @@ export default {
return null
},
init() {
var bookshelfCoverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var sizeIndex = this.availableSizes.findIndex((s) => s === bookshelfCoverSize)
if (!isNaN(sizeIndex)) this.selectedSizeIndex = sizeIndex
this.calculateBookshelf()
},
resize() {
@@ -88,11 +128,17 @@ export default {
console.log('[AudioBookshelf] Audiobooks Updated')
this.setGroupedBooks()
},
settingsUpdated() {
// var newSortKey = `${this.orderBy}-${this.orderDesc}`
settingsUpdated(settings) {
if (this.currFilterOrderKey !== this.filterOrderKey) {
this.setGroupedBooks()
}
if (settings.bookshelfCoverSize !== this.bookCoverWidth && settings.bookshelfCoverSize !== undefined) {
var index = this.availableSizes.indexOf(settings.bookshelfCoverSize)
if (index >= 0) {
this.selectedSizeIndex = index
this.resize()
}
}
},
scan() {
this.$root.socket.emit('scan')
@@ -100,7 +146,7 @@ export default {
},
mounted() {
this.$store.commit('audiobooks/addListener', { id: 'bookshelf', meth: this.audiobooksUpdated })
this.$store.commit('settings/addListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.commit('user/addSettingsListener', { id: 'bookshelf', meth: this.settingsUpdated })
this.$store.dispatch('audiobooks/load')
this.init()
@@ -108,7 +154,7 @@ export default {
},
beforeDestroy() {
this.$store.commit('audiobooks/removeListener', 'bookshelf')
this.$store.commit('settings/removeListener', 'bookshelf')
this.$store.commit('user/removeSettingsListener', 'bookshelf')
window.removeEventListener('resize', this.resize)
}
}
+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">
<p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" />
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" />
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" />
<span class="px-4 text-sm">by</span>
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" />
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" />
</div>
</div>
</template>
@@ -14,7 +14,8 @@
export default {
data() {
return {
settings: {}
settings: {},
hasInit: false
}
},
computed: {
@@ -30,15 +31,24 @@ export default {
this.saveSettings()
},
saveSettings() {
// Send to server
this.$store.commit('settings/setSettings', this.settings)
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
},
init() {
this.settings = { ...this.$store.state.settings.settings }
this.settings = { ...this.$store.state.user.settings }
},
settingsUpdated(settings) {
for (const key in settings) {
this.settings[key] = settings[key]
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
}
}
</script>
+6 -4
View File
@@ -1,5 +1,5 @@
<template>
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-20 bg-primary p-4">
<div v-if="streamAudiobook" id="streamContainer" class="w-full fixed bottom-0 left-0 right-0 h-40 z-40 bg-primary p-4">
<div class="absolute -top-16 left-4">
<cards-book-cover :audiobook="streamAudiobook" :width="88" />
</div>
@@ -22,6 +22,7 @@
export default {
data() {
return {
audioPlayerReady: false,
lastServerUpdateSentSeconds: 0,
stream: null
}
@@ -32,7 +33,7 @@ export default {
return 'Logo.png'
},
user() {
return this.$store.state.user
return this.$store.state.user.user
},
isLoading() {
if (!this.streamAudiobook) return false
@@ -63,6 +64,7 @@ export default {
},
methods: {
audioPlayerMounted() {
this.audioPlayerReady = true
if (this.stream) {
console.log('[STREAM-CONTAINER] audioPlayerMounted w/ Stream', this.stream)
this.openStream()
@@ -104,12 +106,12 @@ export default {
if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream)
this.openStream()
} else {
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
}
},
streamClosed(streamId) {
if (this.stream && this.stream.id === streamId) {
if (this.stream && (this.stream.id === streamId || streamId === 'n/a')) {
this.terminateStream()
this.$store.commit('clearStreamAudiobook', this.stream.audiobook.id)
this.stream = null
@@ -0,0 +1,46 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<cards-book-cover :audiobook="audiobook" :width="40" />
<div class="flex-grow px-2 searchCardContent h-full">
<p class="truncate text-sm">{{ title }}</p>
<p class="text-xs text-gray-200 truncate">by {{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : 'No Title'
},
author() {
return this.book ? this.book.author : 'Unknown'
}
},
methods: {},
mounted() {}
}
</script>
<style>
.searchCardContent {
width: calc(100% - 80px);
height: calc(40px * 1.5);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>
+102 -28
View File
@@ -1,29 +1,42 @@
<template>
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
<cards-book-cover :audiobook="audiobook" />
<div class="relative">
<!-- New Book Flag -->
<div v-show="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
<div class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons text-5xl">play_circle_filled</span>
<div class="rounded-sm h-full overflow-hidden relative" :style="{ padding: `16px ${paddingX}px` }" @mouseover="isHovering = true" @mouseleave="isHovering = false" @click="clickCard">
<nuxt-link :to="isSelectionMode ? '' : `/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative box-shadow-book" :style="{ height: height + 'px' }">
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
<div v-show="isHovering || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black rounded" :class="overlayWrapperClasslist">
<div v-show="!isSelectionMode" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div>
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div>
</div>
<div class="absolute top-1.5 right-1.5 cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" @click.stop.prevent="editClick">
<span class="material-icons" style="font-size: 16px">edit</span>
</div>
</div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
</div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
<div class="h-6 w-10 bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-sm text-red-100 pr-1">priority_high</span>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
</div>
</ui-tooltip>
</nuxt-link>
</div>
</nuxt-link>
</div>
</template>
<script>
@@ -36,6 +49,10 @@ export default {
userProgress: {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
}
},
data() {
@@ -44,24 +61,67 @@ export default {
}
},
computed: {
isNew() {
return this.tags.includes('new')
},
tags() {
return this.audiobook.tags || []
},
audiobookId() {
return this.audiobook.id
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
},
selectedAudiobooks() {
return this.$store.state.selectedAudiobooks
},
selected() {
return this.$store.getters['getIsAudiobookSelected'](this.audiobookId)
},
processingBatch() {
return this.$store.state.processingBatch
},
book() {
return this.audiobook.book || {}
},
width() {
return 120
},
height() {
return this.width * 1.6
},
sizeMultiplier() {
return this.width / 120
},
paddingX() {
return 16 * this.sizeMultiplier
},
title() {
return this.book.title
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
authorFormat() {
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
},
volumeNumber() {
return this.book.volumeNumber || null
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
@@ -84,9 +144,22 @@ export default {
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
}
},
methods: {
selectBtnClick() {
if (this.processingBatch) return
this.$store.commit('toggleAudiobookSelected', this.audiobookId)
},
clickError(e) {
e.stopPropagation()
this.$router.push(`/audiobook/${this.audiobookId}`)
@@ -97,14 +170,15 @@ export default {
},
editClick() {
this.$store.commit('showEditModal', this.audiobook)
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
}
}
},
mounted() {}
}
</script>
<style>
.bookCard {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
</style>
+57 -4
View File
@@ -1,13 +1,19 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: width * 1.6 + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<img ref="cover" :src="cover" @error="imageError" class="w-full h-full object-cover" />
<div class="w-full h-full relative">
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/LogoTransparent.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
@@ -26,6 +32,7 @@ export default {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
@@ -33,7 +40,13 @@ export default {
},
data() {
return {
imageFailed: false
imageFailed: false,
showCoverBg: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
@@ -50,6 +63,7 @@ export default {
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
@@ -58,8 +72,22 @@ export default {
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (!this.cover || this.cover === this.placeholderUrl) return ''
if (this.cover.startsWith('http:') || this.cover.startsWith('https:')) return this.cover
try {
var url = new URL(this.cover, document.baseURI)
return url.href
} catch (err) {
console.error(err)
return ''
}
},
cover() {
return this.book.cover || '/book_placeholder.jpg'
return this.book.cover || this.placeholderUrl
},
hasCover() {
return !!this.book.cover
@@ -81,6 +109,31 @@ export default {
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
this.$refs.coverBg.style.backgroundSize = 'cover'
this.$refs.coverBg.style.backgroundPosition = 'center'
this.$refs.coverBg.style.opacity = 0.25
this.$refs.coverBg.style.filter = 'blur(1px)'
}
},
hideCoverBg() {},
imageLoaded() {
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - 1.6)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
console.error('ImgError', err)
this.imageFailed = true
+18 -2
View File
@@ -1,14 +1,17 @@
<template>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span v-if="selected === 'all'" class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span>
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
<span class="material-icons" style="font-size: 1.1rem">close</span>
</div>
</button>
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
@@ -73,6 +76,11 @@ export default {
text: 'Tag',
value: 'tags',
sublist: true
},
{
text: 'Series',
value: 'series',
sublist: true
}
]
}
@@ -113,11 +121,19 @@ export default {
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
},
sublistItems() {
return this[this.sublist] || []
}
},
methods: {
clearSelected() {
this.selected = 'all'
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
+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>
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-300 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
<span class="flex items-center justify-between">
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
</span>
<!-- <span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</span> -->
</button>
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@@ -42,8 +37,12 @@ export default {
value: 'book.title'
},
{
text: 'Author',
value: 'book.author'
text: 'Author (First Last)',
value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
},
{
text: 'Added At',
@@ -78,7 +77,8 @@ export default {
}
},
selectedText() {
var _sel = this.items.find((i) => i.value === this.selected)
var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return ''
return _sel.text
}
@@ -8,12 +8,24 @@
<div class="arrow-down" />
</div>
<div class="w-full h-full no-scroll flex">
<template v-for="(rate, index) in rates">
<div :key="rate" class="flex items-center justify-center border-black-300 w-11 hover:bg-black hover:bg-opacity-10 cursor-pointer" :class="index < rates.length - 1 ? 'border-r' : ''" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<p class="text-xs text-center font-mono">{{ rate.toFixed(1) }}<span class="text-sm"></span></p>
<div class="w-full h-full no-scroll flex px-7 relative overflow-hidden">
<div class="absolute left-0 top-0 h-full w-7 border-r border-black-300 bg-black-300 rounded-l-lg flex items-center justify-center cursor-pointer" :class="rateIndex === 0 ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="leftArrowClick">
<span class="material-icons" style="font-size: 1.2rem">chevron_left</span>
</div>
<div class="overflow-hidden relative" style="width: 220px">
<div class="flex items-center h-full absolute top-0 left-0 transition-transform duration-100" :style="{ transform: `translateX(${xPos}px)` }">
<template v-for="rate in rates">
<div :key="rate" class="h-full border-black-300 w-11 cursor-pointer border-r" :class="value === rate ? 'bg-black-100' : 'hover:bg-black hover:bg-opacity-10'" style="min-width: 44px; max-width: 44px" @click="set(rate)">
<div class="w-full h-full flex justify-center items-center">
<p class="text-xs text-center font-mono">{{ rate }}<span class="text-sm"></span></p>
</div>
</div>
</template>
</div>
</template>
</div>
<div class="absolute top-0 right-0 h-full w-7 bg-black-300 rounded-r-lg flex items-center justify-center cursor-pointer" :class="rateIndex === rates.length - numVisible ? 'bg-black-400 text-gray-400' : 'hover:bg-black-200'" @mousedown.prevent @mouseup.prevent @click="rightArrowClick">
<span class="material-icons" style="font-size: 1.2rem">chevron_right</span>
</div>
</div>
</div>
</div>
@@ -29,7 +41,9 @@ export default {
},
data() {
return {
showMenu: false
showMenu: false,
rateIndex: 1,
numVisible: 5
}
},
computed: {
@@ -42,7 +56,10 @@ export default {
}
},
rates() {
return [0.5, 0.8, 1.0, 1.3, 1.5, 2.0]
return [0.25, 0.5, 0.8, 1, 1.3, 1.5, 2, 2.5, 3]
},
xPos() {
return -1 * this.rateIndex * 44
}
},
methods: {
@@ -55,6 +72,12 @@ export default {
this.playbackRate = newPlaybackRate
if (hasChanged) this.$emit('change', newPlaybackRate)
this.showMenu = false
},
leftArrowClick() {
this.rateIndex = Math.max(0, this.rateIndex - 4)
},
rightArrowClick() {
this.rateIndex = Math.min(this.rates.length - this.numVisible, this.rateIndex + 4)
}
},
mounted() {}
+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>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'details' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('details')">Details</div>
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'cover' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('cover')">Cover</div>
<div class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'match' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('match')">Match</div>
<div class="w-28 rounded-t-lg flex items-center justify-center cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === 'tracks' ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab('tracks')">Tracks</div>
<template v-for="tab in tabs">
<div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div>
</template>
</div>
<div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
@@ -25,18 +24,48 @@ export default {
return {
selectedTab: 'details',
processing: false,
audiobook: null
audiobook: null,
fetchOnShow: false,
tabs: [
{
id: 'details',
title: 'Details',
component: 'modals-edit-tabs-details'
},
{
id: 'cover',
title: 'Cover',
component: 'modals-edit-tabs-cover'
},
{
id: 'match',
title: 'Match',
component: 'modals-edit-tabs-match'
},
{
id: 'tracks',
title: 'Tracks',
component: 'modals-edit-tabs-tracks'
},
{
id: 'download',
title: 'Download',
component: 'modals-edit-tabs-download'
}
]
}
},
watch: {
show: {
handler(newVal) {
if (newVal) {
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) return
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull()
return
}
this.fetchOnShow = false
this.audiobook = null
this.init()
} else {
this.$store.commit('audiobooks/removeListener', 'edit-modal')
}
}
}
@@ -51,11 +80,8 @@ export default {
}
},
tabName() {
if (this.selectedTab === 'details') return 'modals-edit-tabs-details'
else if (this.selectedTab === 'cover') return 'modals-edit-tabs-cover'
else if (this.selectedTab === 'match') return 'modals-edit-tabs-match'
else if (this.selectedTab === 'tracks') return 'modals-edit-tabs-tracks'
return ''
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
@@ -75,7 +101,10 @@ export default {
this.selectedTab = tab
},
audiobookUpdated() {
this.fetchFull()
if (!this.show) this.fetchOnShow = true
else {
this.fetchFull()
}
},
init() {
this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId })
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-50 flex items-center justify-center z-30 opacity-0">
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-30 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div class="absolute top-5 right-5 h-12 w-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
+67 -8
View File
@@ -1,7 +1,16 @@
<template>
<div class="w-full h-full">
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="flex">
<cards-book-cover :audiobook="audiobook" />
<div class="relative">
<cards-book-cover :audiobook="audiobook" />
<!-- book cover overlay -->
<div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
<div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" />
<div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover">
<span class="material-icons">delete</span>
</div>
</div>
</div>
<div class="flex-grow pl-6 pr-2">
<form @submit.prevent="submitForm">
<div class="flex items-center">
@@ -10,6 +19,24 @@
</div>
</form>
<div v-if="localCovers.length" class="mb-4 mt-6 border-t border-b border-primary">
<div class="flex items-center justify-center py-2">
<p>{{ localCovers.length }} local image(s)</p>
<div class="flex-grow" />
<ui-btn small @click="showLocalCovers = !showLocalCovers">{{ showLocalCovers ? 'Hide' : 'Show' }}</ui-btn>
</div>
<div v-if="showLocalCovers" class="flex items-center justify-center">
<template v-for="cover in localCovers">
<div :key="cover.path" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.localPath === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover.localPath)">
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover.localPath" class="h-full w-full object-contain" />
</div>
</div>
</template>
</div>
</div>
<form @submit.prevent="submitSearchForm">
<div class="flex items-center justify-start -mx-1 py-2 mt-2">
<div class="flex-grow px-1">
@@ -23,11 +50,14 @@
</div>
</div>
</form>
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-72 overflow-y-scroll mt-2 max-w-full">
<div v-if="hasSearched" class="flex items-center flex-wrap justify-center max-h-60 overflow-y-scroll mt-2 max-w-full">
<p v-if="!coversFound.length">No Covers Found</p>
<template v-for="cover in coversFound">
<div :key="cover" class="m-0.5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover === imageUrl ? 'border-yellow-300' : ''" @click="setCover(cover)">
<img :src="cover" class="h-24 object-cover" style="width: 60px" />
<div class="h-24 bg-primary" style="width: 60px">
<img :src="cover" class="h-full w-full object-contain" />
</div>
<!-- <img :src="cover" class="h-24 object-cover" style="width: 60px" /> -->
</div>
</template>
</div>
@@ -37,6 +67,8 @@
</template>
<script>
import Path from 'path'
export default {
props: {
processing: Boolean,
@@ -51,14 +83,17 @@ export default {
searchAuthor: null,
imageUrl: null,
coversFound: [],
hasSearched: false
hasSearched: false,
showLocalCovers: false
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
if (newVal) {
this.init()
}
}
}
},
@@ -73,10 +108,23 @@ export default {
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
localCovers() {
return this.otherFiles
.filter((f) => f.filetype === 'image')
.map((file) => {
var _file = { ...file }
_file.localPath = Path.join('local', _file.path)
return _file
})
}
},
methods: {
init() {
this.showLocalCovers = false
if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.author)) {
this.coversFound = []
this.hasSearched = false
@@ -85,10 +133,22 @@ export default {
this.searchTitle = this.book.title || ''
this.searchAuthor = this.book.author || ''
},
removeCover() {
if (!this.book.cover) {
this.imageUrl = ''
return
}
this.updateCover('')
},
submitForm() {
this.updateCover(this.imageUrl)
},
async updateCover(cover) {
if (cover === this.book.cover) {
console.warn('Cover has not changed..', cover)
return
}
this.isProcessing = true
const updatePayload = {
book: {
@@ -101,13 +161,12 @@ export default {
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
},
getSearchQuery() {
var searchQuery = `provider=best&title=${this.searchTitle}`
var searchQuery = `provider=openlibrary&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
+38 -14
View File
@@ -21,11 +21,25 @@
</div>
</div>
<ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
<ui-multi-select v-model="details.genres" label="Genre" :items="genres" class="mt-2" @addOption="addGenre" />
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-multi-select v-model="details.genres" label="Genres" :items="genres" />
</div>
<div class="flex-grow px-1">
<ui-multi-select v-model="newTags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex py-4">
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
@@ -52,11 +66,12 @@ export default {
description: null,
author: null,
series: null,
volumeNumber: null,
publishYear: null,
genres: []
},
resettingProgress: false,
genres: ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
newTags: [],
resettingProgress: false
}
},
watch: {
@@ -83,32 +98,38 @@ export default {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['getUserAudiobook'](this.audiobookId)
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
genres() {
return this.$store.state.audiobooks.genres
},
tags() {
return this.$store.state.audiobooks.tags
},
series() {
return this.$store.state.audiobooks.series
}
},
methods: {
addGenre(genre) {
this.genres.push({
text: genre,
value: genre
})
},
async submitForm() {
console.log('Submit form', this.details)
if (this.isProcessing) {
return
}
this.isProcessing = true
const updatePayload = {
book: this.details
book: this.details,
tags: this.newTags
}
var updatedAudiobook = await this.$axios.$patch(`/api/audiobook/${this.audiobook.id}`, updatePayload).catch((error) => {
console.error('Failed to update', error)
return false
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
@@ -119,7 +140,10 @@ export default {
this.details.author = this.book.author
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear
this.newTags = this.audiobook.tags || []
},
resetProgress() {
if (confirm(`Are you sure you want to reset your progress?`)) {
@@ -0,0 +1,154 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full border border-black-200 p-4 my-4">
<p class="text-center text-lg mb-4 pb-8 border-b border-black-200">
<span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in
<span class="bg-primary bg-opacity-75 font-mono p-1 text-base">/metadata/downloads</span>. After the download is ready, it will remain available for 10 minutes then get deleted.
</p>
<div class="flex items-center">
<p class="text-lg">Single audio file</p>
<div class="flex-grow" />
<div>
<p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p>
<p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p>
<p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
</div>
</div>
</div>
<div v-if="isDownloading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="w-80 border border-black-400 bg-bg rounded-xl h-20">
<div class="w-full h-full flex items-center justify-center">
<p class="text-lg">Download.... {{ downloadPercent }}%</p>
<p class="w-24 font-mono pl-8 text-right">
{{ downloadAmount }}
</p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
processing: Boolean,
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
tempDisable: false,
isDownloading: false,
downloadPercent: '0',
downloadAmount: '0 KB'
}
},
watch: {
singleAudioDownloadPending(newVal) {
if (newVal) {
this.tempDisable = false
}
}
},
computed: {
audiobookId() {
return this.audiobook ? this.audiobook.id : null
},
downloads() {
return this.$store.getters['downloads/getDownloads'](this.audiobookId)
},
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleAudioDownloadPending() {
return this.singleAudioDownload && this.singleAudioDownload.isPending
},
singleAudioDownloadFailed() {
return this.singleAudioDownload && this.singleAudioDownload.isFailed
},
singleAudioDownloadReady() {
return this.singleAudioDownload && this.singleAudioDownload.isReady
},
singleAudioDownloadExpired() {
return this.singleAudioDownload && this.singleAudioDownload.isExpired
},
zipBundleDownload() {
return this.downloads.find((d) => d.type === 'zipBundle')
}
},
methods: {
startSingleAudioDownload() {
console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio'
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress() {
var downloadId = this.singleAudioDownload.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = this.singleAudioDownload.filename
this.isDownloading = true
var request = new XMLHttpRequest()
request.responseType = 'blob'
request.open('get', downloadUrl, true)
request.setRequestHeader('Authorization', `Bearer ${this.$store.getters['user/getToken']}`)
request.send()
request.onreadystatechange = () => {
if (request.readyState === 4) {
this.isDownloading = false
}
if (request.readyState == 4 && request.status == 200) {
const url = window.URL.createObjectURL(request.response)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
document.body.appendChild(anchor)
anchor.click()
setTimeout(() => {
if (anchor) anchor.remove()
}, 1000)
}
}
request.onerror = (err) => {
console.error('Download error', err)
this.isDownloading = false
}
request.onprogress = (e) => {
const percent_complete = Math.floor((e.loaded / e.total) * 100)
this.downloadAmount = this.$bytesPretty(e.loaded)
this.downloadPercent = percent_complete
// const duration = (new Date().getTime() - startTime) / 1000
// const bps = e.loaded / duration
// const kbps = Math.floor(bps / 1024)
// const time = (e.total - e.loaded) / bps
// const seconds = Math.floor(time % 60)
// const minutes = Math.floor(time / 60)
// console.log(`${percent_complete}% - ${kbps} Kbps - ${minutes} min ${seconds} sec remaining`)
}
}
},
mounted() {}
}
</script>
+13 -5
View File
@@ -15,7 +15,7 @@
<div v-show="processing" class="flex h-full items-center justify-center">
<p>Loading...</p>
</div>
<div v-show="!processing && !searchResults.length" class="flex h-full items-center justify-center">
<div v-show="!processing && !searchResults.length && hasSearched" class="flex h-full items-center justify-center">
<p>No Results</p>
</div>
<div v-show="!processing" class="w-full max-h-full overflow-y-auto overflow-x-hidden matchListWrapper">
@@ -37,11 +37,13 @@ export default {
},
data() {
return {
audiobookId: null,
searchTitle: null,
searchAuthor: null,
lastSearch: null,
provider: 'best',
searchResults: []
searchResults: [],
hasSearched: false
}
},
watch: {
@@ -64,7 +66,7 @@ export default {
},
methods: {
getSearchQuery() {
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
var searchQuery = `provider=${this.provider}&fallbackTitleOnly=1&title=${this.searchTitle}`
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
return searchQuery
},
@@ -90,15 +92,22 @@ export default {
})
this.searchResults = results
this.isProcessing = false
this.hasSearched = true
},
init() {
if (this.audiobook.id !== this.audiobookId) {
this.searchResults = []
this.hasSearched = false
this.audiobookId = this.audiobook.id
}
if (!this.audiobook.book || !this.audiobook.book.title) {
this.searchTitle = null
this.searchAuthor = null
return
}
this.searchTitle = this.audiobook.book.title
this.searchAuthor = this.audiobook.book.author || ''
this.runSearch()
},
async selectMatch(match) {
this.isProcessing = true
@@ -120,7 +129,6 @@ export default {
})
this.isProcessing = false
if (updatedAudiobook) {
console.log('Update Successful', updatedAudiobook)
this.$toast.success('Update Successful')
this.$emit('close')
}
+15 -3
View File
@@ -1,6 +1,12 @@
<template>
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :type="type" :class="classList" @click="click">
<button class="btn outline-none rounded-md shadow-md relative border border-gray-600" :disabled="disabled || loading" :type="type" :class="classList" @click="click">
<slot />
<div v-if="loading" class="text-white absolute top-0 left-0 w-full h-full flex items-center justify-center text-opacity-100">
<!-- <span class="material-icons animate-spin">refresh</span> -->
<svg class="animate-spin" style="width: 24px; height: 24px" viewBox="0 0 24 24">
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
</svg>
</div>
</button>
</template>
@@ -16,7 +22,9 @@ export default {
default: ''
},
paddingX: Number,
small: Boolean
small: Boolean,
loading: Boolean,
disabled: Boolean
},
data() {
return {}
@@ -24,6 +32,7 @@ export default {
computed: {
classList() {
var list = []
if (this.loading) list.push('text-opacity-0')
list.push('text-white')
list.push(`bg-${this.color}`)
if (this.small) {
@@ -61,7 +70,10 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover::before {
button.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+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">
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
<template v-for="item in items">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
</li>
</nuxt-link>
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
<div class="flex items-center">
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
</div>
+25 -33
View File
@@ -4,7 +4,12 @@
<div ref="wrapper" class="relative">
<form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ snakeToNormal(item) }}</div>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ $snakeToNormal(item) }}
</div>
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
@@ -13,7 +18,7 @@
<template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ snakeToNormal(item) }}</span>
<span class="font-normal ml-3 block truncate">{{ $snakeToNormal(item) }}</span>
</div>
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">checkmark</span>
@@ -47,7 +52,6 @@ export default {
return {
textInput: null,
currentSearch: null,
isTyping: false,
typingTimeout: null,
isFocused: false,
menu: null
@@ -71,38 +75,15 @@ export default {
}
return this.items.filter((i) => {
var normie = this.snakeToNormal(i)
var normie = this.$snakeToNormal(i)
var iValue = String(normie).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
},
methods: {
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
normalToSnake(normie) {
return normie
.trim()
.split(' ')
.map((t) => t.toLowerCase())
.join('_')
},
setMatchingItems() {
if (!this.textInput) {
return
}
this.currentSearch = this.textInput
},
keydownInput() {
clearTimeout(this.typingTimeout)
this.isTyping = true
this.typingTimeout = setTimeout(() => {
this.currentSearch = this.textInput
}, 100)
@@ -156,8 +137,10 @@ export default {
if (this.$refs.input) this.$refs.input.blur()
},
clickedOption(e, itemValue) {
e.stopPropagation()
e.preventDefault()
if (e) {
e.stopPropagation()
e.preventDefault()
}
if (this.$refs.input) this.$refs.input.focus()
var newSelected = null
@@ -169,6 +152,9 @@ export default {
this.textInput = null
this.currentSearch = null
this.$emit('input', newSelected)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
clickWrapper() {
if (this.showMenu) {
@@ -176,10 +162,16 @@ export default {
}
this.focus()
},
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) {
var kebabItem = this.normalToSnake(item)
var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem)
this.$emit('addOption', kebabItem)
this.$emit('input', this.selected)
this.textInput = null
this.currentSearch = null
@@ -191,12 +183,12 @@ export default {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
var cleanedKebab = this.normalToSnake(cleaned)
var cleanedKebab = this.$normalToSnake(cleaned)
var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i
})
if (matchesItem) {
this.clickedOption(matchesItem.value)
this.clickedOption(null, matchesItem)
} else {
this.insertNewItem(this.textInput)
}
+10 -1
View File
@@ -1,5 +1,5 @@
<template>
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
@@ -29,8 +29,17 @@ export default {
}
},
methods: {
focused() {
this.$emit('focus')
},
blurred() {
this.$emit('blur')
},
change(e) {
this.$emit('change', e.target.value)
},
keyup(e) {
this.$emit('keyup', e)
}
},
mounted() {}
+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: {
type: String,
required: true
},
direction: {
type: String,
default: 'right'
}
},
data() {
@@ -21,11 +25,17 @@ export default {
methods: {
createTooltip() {
var boxChow = this.$refs.box.getBoundingClientRect()
var top = boxChow.top
var left = boxChow.left + boxChow.width + 4
var top = 0
var left = 0
if (this.direction === 'right') {
top = boxChow.top
left = boxChow.left + boxChow.width + 4
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left
}
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-60 py-1 text-white pointer-events-none text-xs'
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
tooltip.style.top = top + 'px'
tooltip.style.left = left + 'px'
tooltip.style.zIndex = 100
+32 -5
View File
@@ -1,25 +1,47 @@
<template>
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg">
<div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" />
</div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div>
</template>
<script>
export default {
data() {
return {}
return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
},
computed: {
text() {
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
},
isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() {
return this.$store.state.scanProgress
return this.$store.state[this.scanProgressKey]
},
scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
@@ -31,7 +53,12 @@ export default {
return this.scanProgress ? this.scanProgress.total : 0
}
},
methods: {},
methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {}
}
</script>
+87 -23
View File
@@ -10,6 +10,7 @@
<script>
export default {
middleware: 'authenticated',
data() {
return {
socket: null
@@ -20,17 +21,20 @@ export default {
if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false)
}
if (this.$store.state.selectedAudiobooks) {
this.$store.commit('setSelectedAudiobooks', [])
}
}
},
computed: {
user() {
return this.$store.state.user
return this.$store.state.user.user
}
},
methods: {
connect() {
console.log('[SOCKET] Connected')
var token = this.$store.getters.getToken
var token = this.$store.getters['user/getToken']
this.socket.emit('auth', token)
},
connectError() {},
@@ -49,7 +53,8 @@ export default {
}
}
if (payload.user) {
this.$store.commit('setUser', payload.user)
this.$store.commit('user/setUser', payload.user)
this.$store.commit('user/setSettings', payload.user.settings)
}
},
streamOpen(stream) {
@@ -81,21 +86,79 @@ export default {
}
this.$store.commit('audiobooks/remove', audiobook)
},
scanComplete() {
this.$store.commit('setIsScanning', false)
this.$toast.success('Scan Finished')
scanComplete({ scanType, results }) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', false)
if (results) {
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
}
} else {
this.$store.commit('setIsScanning', false)
if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
},
scanStart() {
this.$store.commit('setIsScanning', true)
scanStart(scanType) {
if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
},
scanProgress(progress) {
this.$store.commit('setScanProgress', progress)
scanProgress({ scanType, progress }) {
if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
} else {
this.$store.commit('setScanProgress', progress)
}
},
userUpdated(user) {
if (this.$store.state.user.id === user.id) {
this.$store.commit('setUser', user)
if (this.$store.state.user.user.id === user.id) {
this.$store.commit('user/setUser', user)
this.$store.commit('user/setSettings', user.settings)
}
},
downloadStarted(download) {
var filename = download.filename
this.$toast.success(`Preparing download for "${filename}"`)
download.isPending = true
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadReady(download) {
var filename = download.filename
this.$toast.success(`Download "${filename}" is ready!`)
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadFailed(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" is failed`)
download.isFailed = true
download.isReady = false
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadKilled(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" was terminated`)
this.$store.commit('downloads/removeDownload', download)
},
downloadExpired(download) {
download.isExpired = true
download.isReady = false
download.isPending = false
this.$store.commit('downloads/addUpdateDownload', download)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
@@ -136,21 +199,22 @@ export default {
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
},
checkVersion() {
this.$axios.$get('http://github.com/advplyr/audiobookshelf/raw/master/package.json').then((data) => {
console.log('GOT DATA', data)
})
}
},
beforeMount() {
if (!this.$store.state.user) {
this.$router.replace(`/login?redirect=${this.$route.path}`)
// Download Listeners
this.socket.on('download_started', this.downloadStarted)
this.socket.on('download_ready', this.downloadReady)
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
}
},
mounted() {
this.initializeSocket()
this.checkVersion()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
}
}
</script>
+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",
"version": "0.9.6-beta",
"version": "1.0.0",
"description": "Audiobook manager and player",
"main": "index.js",
"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>
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
<ui-loading-indicator />
</div>
@@ -66,7 +66,7 @@ export default {
draggable
},
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
+54 -8
View File
@@ -1,5 +1,5 @@
<template>
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto p-8">
<div class="flex max-w-6xl mx-auto">
<div class="w-52" style="min-width: 208px">
@@ -10,19 +10,27 @@
</div>
<div class="flex-grow px-10">
<div class="flex">
<h1 class="text-2xl">{{ title }}</h1>
<div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<ui-tooltip :text="authorTooltipText" direction="bottom">
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
</ui-tooltip>
</div>
<div class="flex-grow" />
</div>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div class="flex items-center pt-4">
<ui-btn color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
Play
<ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
{{ streaming ? 'Streaming' : 'Play' }}
</ui-btn>
<ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn>
<ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
@@ -44,7 +52,9 @@
<p class="text-sm mb-2">
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<p class="text-sm font-mono">{{ invalidParts.join(', ') }}</p>
<div>
<p v-for="part in invalidParts" :key="part" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
<tables-tracks-table :tracks="tracks" :audiobook-id="audiobook.id" class="mt-6" />
@@ -61,7 +71,7 @@
<script>
export default {
async asyncData({ store, params, app, redirect, route }) {
if (!store.state.user) {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
@@ -82,6 +92,9 @@ export default {
}
},
computed: {
isDeveloperMode() {
return this.$store.state.developerMode
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
@@ -128,6 +141,27 @@ export default {
author() {
return this.book.author || 'Unknown'
},
authorFL() {
return this.book.authorFL
},
authorLF() {
return this.book.authorLF
},
authorTooltipText() {
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
return txt.join('\n')
},
series() {
return this.book.series || null
},
volumeNumber() {
return this.book.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
durationPretty() {
return this.audiobook.durationPretty
},
@@ -158,7 +192,7 @@ export default {
return this.book.description || 'No Description'
},
userAudiobooks() {
return this.$store.state.user ? this.$store.state.user.audiobooks || {} : {}
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null
@@ -180,6 +214,18 @@ export default {
}
},
methods: {
openRssFeed() {
this.$axios
.$post('/api/feed', { audiobookId: this.audiobook.id })
.then((res) => {
console.log('Feed open', res)
this.$toast.success('RSS Feed Open')
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Failed to open feed')
})
},
startStream() {
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$root.socket.emit('open_stream', this.audiobook.id)
+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>
<div class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full max-w-4xl mx-auto">
<h1 class="text-2xl mb-2">Config</h1>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="p-4 text-center h-20">
<p>Nothing much here yet...</p>
<div class="flex items-center mb-2">
<h1 class="text-2xl">Users</h1>
<div class="mx-2 w-7 h-7 flex items-center justify-center rounded-full cursor-pointer hover:bg-white hover:bg-opacity-10 text-center" @click="clickAddUser">
<span class="material-icons" style="font-size: 1.4rem">add</span>
</div>
<!-- <ui-btn small :padding-x="4" class="h-8">Create User</ui-btn> -->
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex-grow" />
<ui-btn color="success" @click="scan">Scan</ui-btn>
<div class="p-4 text-center">
<table id="accounts" class="mb-8">
<tr>
<th>Username</th>
<th>Account Type</th>
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
<td>{{ user.type }}</td>
<td class="text-sm font-mono">
{{ new Date(user.createdAt).toISOString() }}
</td>
<td>
<div class="w-full flex justify-center">
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div>
</td>
</tr>
</table>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8">
<div class="flex items-start py-2">
<p class="text-2xl">Scanner</p>
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</div>
</div>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<ui-btn color="bg" small :padding-x="4" :loading="isResettingAudiobooks" @click="resetAudiobooks">Reset All Audiobooks</ui-btn>
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4">
<p class="font-mono">v{{ $config.version }}</p>
<div class="flex-grow" />
@@ -26,24 +66,168 @@
</a>
</div>
</div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" />
</div>
</template>
<script>
export default {
asyncData({ store, redirect }) {
if (!store.getters['user/getIsRoot']) {
redirect('/?error=unauthorized')
}
},
data() {
return {}
return {
isResettingAudiobooks: false,
users: [],
showAccountModal: false,
isDeletingUser: false
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
isScanning() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
}
},
methods: {
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
this.$toast.info(`Developer Mode ${value ? 'Enabled' : 'Disabled'}`)
},
scan() {
this.$root.socket.emit('scan')
},
scanCovers() {
this.$root.socket.emit('scan_covers')
},
clickAddUser() {
this.showAccountModal = true
// this.$toast.info('Under Construction: User management coming soon.')
},
loadUsers() {
this.$axios
.$get('/api/users')
.then((users) => {
this.users = users
})
.catch((error) => {
console.error('Failed', error)
})
},
resetAudiobooks() {
if (confirm('WARNING! This action will remove all audiobooks from the database including any updates or matches you have made. This does not do anything to your actual files. Shall we continue?')) {
this.isResettingAudiobooks = true
this.$axios
.$delete('/api/audiobooks')
.then(() => {
this.isResettingAudiobooks = false
this.$toast.success('Successfully reset audiobooks')
})
.catch((error) => {
console.error('failed to reset audiobooks', error)
this.isResettingAudiobooks = false
this.$toast.error('Failed to reset audiobooks - stop docker and manually remove appdata')
})
}
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
this.isDeletingUser = true
this.$axios
.$delete(`/api/user/${user.id}`)
.then((data) => {
this.isDeletingUser = false
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('User deleted')
}
})
.catch((error) => {
console.error('Failed to delete user', error)
this.$toast.error('Failed to delete user')
this.isDeletingUser = false
})
}
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.find((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
this.users.push(user)
}
},
userRemoved(user) {
this.users = this.users.filter((u) => u.id !== user.id)
},
init(attempts = 0) {
if (!this.$root.socket) {
if (attempts > 10) {
return console.error('Failed to setup socket listeners')
}
setTimeout(() => {
this.init(++attempts)
}, 250)
return
}
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
}
},
mounted() {}
mounted() {
this.loadUsers()
this.init()
},
beforeDestroy() {
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
}
}
}
</script>
</script>
<style>
#accounts {
table-layout: fixed;
border-collapse: collapse;
width: 100%;
}
#accounts td,
#accounts th {
border: 1px solid #2e2e2e;
padding: 8px 8px;
text-align: left;
}
#accounts tr:nth-child(even) {
background-color: #3a3a3a;
}
#accounts tr:hover {
background-color: #444;
}
#accounts th {
font-size: 0.8rem;
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #333;
}
</style>
-3
View File
@@ -7,9 +7,6 @@
<script>
export default {
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
+4 -9
View File
@@ -34,29 +34,24 @@ export default {
watch: {
user(newVal) {
if (newVal) {
// if (process.env.NODE_ENV !== 'production') {
if (this.$route.query.redirect) {
this.$router.replace(this.$route.query.redirect)
} else {
this.$router.replace('/')
}
// } else {
// window.location.reload()
// }
}
}
},
computed: {
user() {
return this.$store.state.user
return this.$store.state.user.user
}
},
methods: {
async submitForm() {
this.error = null
this.processing = true
// var uri = `${process.env.serverUrl}/auth`
var payload = {
username: this.username,
password: this.password || ''
@@ -71,7 +66,7 @@ export default {
} else if (authRes.error) {
this.error = authRes.error
} else {
this.$store.commit('setUser', authRes.user)
this.$store.commit('user/setUser', authRes.user)
}
this.processing = false
},
@@ -90,7 +85,7 @@ export default {
}
})
.then((res) => {
this.$store.commit('setUser', res.user)
this.$store.commit('user/setUser', res.user)
this.processing = false
})
.catch((error) => {
+1 -1
View File
@@ -4,7 +4,7 @@ export default function ({ $axios, store }) {
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}
var bearerToken = store.state.user ? store.state.user.token : null
var bearerToken = store.state.user.user ? store.state.user.user.token : null
// console.log('Bearer token', bearerToken)
if (bearerToken) {
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
+86
View File
@@ -38,6 +38,92 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
}
Vue.prototype.$snakeToNormal = (snake) => {
if (!snake) {
return ''
}
return String(snake)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
}
Vue.prototype.$normalToSnake = (normie) => {
if (!normie) return ''
return normie
.trim()
.split(' ')
.map((t) => t.toLowerCase())
.join('_')
}
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
const getCharCode = (char) => availableChars.indexOf(char)
const getCharFromCode = (code) => availableChars[Number(code)] || -1
const cleanChar = (char) => getCharCode(char) < 0 ? '?' : char
Vue.prototype.$cleanString = (str) => {
if (!str) return ''
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
var cleaned = ''
for (let i = 0; i < str.length; i++) {
cleaned += cleanChar(str[i])
}
return cleaned
}
Vue.prototype.$stringToCode = (str) => {
if (!str) return ''
var numcode = [...str].map(s => {
return String(getCharCode(s)).padStart(2, '0')
}).join('')
return BigInt(numcode).toString(36)
}
Vue.prototype.$codeToString = (code) => {
if (!code) return ''
var numcode = ''
try {
numcode = [...code].reduce((acc, curr) => {
return BigInt(parseInt(curr, 36)) + BigInt(36) * acc
}, 0n)
} catch (err) {
console.error('numcode fialed', code, err)
}
var numcodestr = String(numcode)
var remainder = numcodestr.length % 2
numcodestr = numcodestr.padStart(numcodestr.length - 1 + remainder, '0')
var finalform = ''
var numChunks = Math.floor(numcodestr.length / 2)
var remaining = numcodestr
for (let i = 0; i < numChunks; i++) {
var chunk = remaining.slice(0, 2)
remaining = remaining.slice(2)
finalform += getCharFromCode(chunk)
}
return finalform
}
function cleanString(str, availableChars) {
var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
var cleaned = ''
for (let i = 0; i < _str.length; i++) {
cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
}
return cleaned
}
export const cleanFilterString = (str) => {
var _str = str.toLowerCase().replace(/ /g, '_')
_str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
return _str
}
function loadImageBlob(uri) {
return new Promise((resolve) => {
const img = document.createElement('img')
-1
View File
@@ -1,6 +1,5 @@
import Vue from "vue";
import Toast from "vue-toastification";
// Import the CSS or use your own!
import "vue-toastification/dist/index.css";
const options = {
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 { cleanFilterString } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
@@ -6,31 +7,29 @@ export const state = () => ({
audiobooks: [],
listeners: [],
genres: [...STANDARD_GENRES],
tags: []
tags: [],
series: []
})
export const getters = {
getFiltered: (state, getters, rootState) => () => {
var filtered = state.audiobooks
var settings = rootState.settings.settings || {}
var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || ''
var filterByParts = filterBy.split('.')
if (filterByParts.length > 1) {
var primary = filterByParts[0]
var secondary = filterByParts[1]
if (primary === 'genres') {
filtered = filtered.filter(ab => {
return ab.book && ab.book.genres.includes(secondary)
})
} else if (primary === 'tags') {
filtered = filtered.filter(ab => ab.tags.includes(secondary))
}
var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = filterBy.replace(`${group}.`, '')
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
}
// TODO: Add filters
return filtered
},
getFilteredAndSorted: (state, getters, rootState) => () => {
var settings = rootState.settings.settings
var settings = rootState.user.settings
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
@@ -38,11 +37,19 @@ export const getters = {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
})
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)]
}
}
export const actions = {
load({ commit }) {
load({ commit, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('audiobooks/load - User not set')
return
}
this.$axios
.$get(`/api/audiobooks`)
.then((data) => {
@@ -65,6 +72,7 @@ export const mutations = {
genres = genres.concat(ab.book.genres)
})
state.genres = [...new Set(genres)] // Remove Duplicates
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// TAGS
var tags = []
@@ -72,6 +80,16 @@ export const mutations = {
tags = tags.concat(ab.tags)
})
state.tags = [...new Set(tags)] // Remove Duplicates
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// SERIES
var series = []
audiobooks.forEach((ab) => {
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
series.push(ab.book.series)
})
state.series = series
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
state.audiobooks = audiobooks
state.listeners.forEach((listener) => {
@@ -80,19 +98,34 @@ export const mutations = {
},
addUpdate(state, audiobook) {
var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null
if (index >= 0) {
origAudiobook = { ...state.audiobooks[index] }
state.audiobooks.splice(index, 1, audiobook)
} else {
state.audiobooks.push(audiobook)
}
// GENRES
if (audiobook.book) {
// GENRES
var newGenres = []
audiobook.book.genres.forEach((genre) => {
if (!state.genres.includes(genre)) newGenres.push(genre)
})
if (newGenres.length) state.genres = state.genres.concat(newGenres)
if (newGenres.length) {
state.genres = state.genres.concat(newGenres)
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
// SERIES
if (audiobook.book.series && !state.series.includes(audiobook.book.series)) {
state.series.push(audiobook.book.series)
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
if (origAudiobook && origAudiobook.book && origAudiobook.book.series) {
var isInAB = state.audiobooks.find(ab => ab.book && ab.book.series === origAudiobook.book.series)
if (!isInAB) state.series = state.series.filter(series => series !== origAudiobook.book.series)
}
}
// TAGS
@@ -100,8 +133,10 @@ export const mutations = {
audiobook.tags.forEach((tag) => {
if (!state.tags.includes(tag)) newTags.push(tag)
})
if (newTags.length) state.tags = state.tags.concat(newTags)
if (newTags.length) {
state.tags = state.tags.concat(newTags)
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
@@ -112,8 +147,8 @@ export const mutations = {
remove(state, audiobook) {
state.audiobooks = state.audiobooks.filter(a => a.id !== audiobook.id)
// GENRES
if (audiobook.book) {
// GENRES
audiobook.book.genres.forEach((genre) => {
if (!STANDARD_GENRES.includes(genre)) {
var isInOtherAB = state.audiobooks.find(ab => {
@@ -125,6 +160,15 @@ export const mutations = {
}
}
})
// SERIES
if (audiobook.book.series) {
var isInOtherAB = state.audiobooks.find(ab => ab.book && ab.book.series === audiobook.book.series)
if (!isInOtherAB) {
// Series not used in any other audiobook - remove it
state.series = state.series.filter(s => s !== audiobook.book.series)
}
}
}
// TAGS
@@ -138,7 +182,6 @@ export const mutations = {
}
})
state.listeners.forEach((listener) => {
if (!listener.audiobookId || listener.audiobookId === audiobook.id) {
listener.meth()
+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 = () => ({
user: null,
streamAudiobook: null,
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
isScanning: false,
scanProgress: null
isScanningCovers: false,
scanProgress: null,
coverScanProgress: null,
developerMode: false,
selectedAudiobooks: [],
processingBatch: false
})
export const getters = {
getToken: (state) => {
return state.user ? state.user.token : null
getIsAudiobookSelected: state => audiobookId => {
return !!state.selectedAudiobooks.includes(audiobookId)
},
getUserAudiobook: (state) => (audiobookId) => {
return state.user && state.user.audiobooks ? state.user.audiobooks[audiobookId] || null : null
}
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
}
export const actions = {
}
export const actions = {}
export const mutations = {
setUser(state, user) {
state.user = user
if (user.token) {
localStorage.setItem('token', user.token)
}
},
setStreamAudiobook(state, audiobook) {
state.playOnLoad = true
state.streamAudiobook = audiobook
@@ -56,8 +51,31 @@ export const mutations = {
setIsScanning(state, isScanning) {
state.isScanning = isScanning
},
setScanProgress(state, progress) {
if (progress > 0) state.isScanning = true
state.scanProgress = progress
setScanProgress(state, scanProgress) {
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = scanProgress
},
setIsScanningCovers(state, isScanningCovers) {
state.isScanningCovers = isScanningCovers
},
setCoverScanProgress(state, coverScanProgress) {
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
state.coverScanProgress = coverScanProgress
},
setDeveloperMode(state, val) {
state.developerMode = val
},
setSelectedAudiobooks(state, audiobooks) {
state.selectedAudiobooks = audiobooks
},
toggleAudiobookSelected(state, audiobookId) {
if (state.selectedAudiobooks.includes(audiobookId)) {
state.selectedAudiobooks = state.selectedAudiobooks.filter(a => a !== audiobookId)
} else {
state.selectedAudiobooks.push(audiobookId)
}
},
setProcessingBatch(state, val) {
state.processingBatch = val
}
}
-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: {
options: {
safelist: [
'bg-success'
'bg-success',
'bg-red-600'
]
}
},
@@ -16,13 +17,13 @@ module.exports = {
},
colors: {
bg: '#373838',
primary: '#262626',
primary: '#232323',
accent: '#1ad691',
error: '#FF5252',
info: '#2196F3',
success: '#4CAF50',
successDark: '#3b8a3e',
warning: '#FB8C00',
darkgreen: 'rgb(34,127,35)',
'black-50': '#bbbbbb',
'black-100': '#666666',
'black-200': '#555555',
+5 -5
View File
@@ -7,12 +7,12 @@
<MyIP/>
<Shell>sh</Shell>
<Privileged>false</Privileged>
<Support>https://hub.docker.com/r/advplyr/audiobookshelf/</Support>
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>Audiobook manager and player</Overview>
<Category>MediaApp:Books MediaServer:Books Status:Beta<</Category>
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &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>
<WebUI>http://[IP]:[PORT:80]</WebUI>
<TemplateURL/>
<TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL>
<Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon>
<ExtraParams/>
<PostArgs/>
@@ -20,7 +20,7 @@
<DateInstalled>1629238508</DateInstalled>
<DonateText/>
<DonateLink/>
<Description>Audiobook manager and player</Description>
<Description>Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Description>
<Networking>
<Mode>bridge</Mode>
<Publish>
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.METADATA_PATH = devEnv.MetadataPath
process.env.AUDIOBOOK_PATH = devEnv.AudiobookPath
process.env.FFMPEG_PATH = devEnv.FFmpegPath
}
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",
"version": "0.9.61-beta",
"version": "1.0.0",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
"dev": "node index.js",
"start": "node index.js",
"release": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks",
"release-dry": "dotenv release-it --disable-metrics --no-npm --npm.skipChecks --dry-run"
"start": "node index.js"
},
"author": "advplyr",
"license": "ISC",
"dependencies": {
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"chokidar": "^3.5.2",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"ip": "^1.1.5",
"jsonwebtoken": "^8.5.1",
"libgen": "^2.1.0",
"njodb": "^0.4.20",
"node-dir": "^0.1.17",
"socket.io": "^4.1.3"
"podcast": "^1.3.0",
"socket.io": "^4.1.3",
"watcher": "^1.2.0"
},
"devDependencies": {
"dotenv-cli": "^4.0.0",
"release-it": "^14.11.5"
}
}
"devDependencies": {}
}
+10 -9
View File
@@ -2,9 +2,11 @@
AudioBookshelf is a self-hosted audiobook server for managing and playing your audiobooks.
**Currently in early beta**
Android app is in beta, try it out on the [Google Play Store](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_bookshelf.png" />
**Free & open source Android/iOS app is in development**
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
#### Folder Structures Supported:
@@ -19,14 +21,15 @@ Title can start with the publish year like so:
```
#### There is still a lot to do:
#### Features coming soon:
* Adding new audiobooks require pressing Scan button again (on settings page)
* Matching is all manual now and only using 1 source (openlibrary)
* Auto add and update audiobooks (currently you need to press scan)
* User permissions & editing users
* Support different views to see more details of each audiobook
* Then comes the mobile app..
* Option to download all files in a zip file
* iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app))
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
<img alt="Screenshot2" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
## Installation
@@ -36,8 +39,6 @@ Built to run in Docker for now (also on Unraid server Community Apps)
docker run -d -p 1337:80 -v /audiobooks:/audiobooks -v /config:/config -v /metadata:/metadata --name audiobookshelf --rm advplyr/audiobookshelf
```
<img alt="Screenshot3" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_audiobook.png" />
## Contributing
Feel free to help out
+194 -11
View File
@@ -1,12 +1,16 @@
const express = require('express')
const Logger = require('./Logger')
const User = require('./objects/User')
const { isObject } = require('./utils/index')
class ApiController {
constructor(db, scanner, auth, streamManager, emitter) {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) {
this.db = db
this.scanner = scanner
this.auth = auth
this.streamManager = streamManager
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.emitter = emitter
this.router = express()
@@ -17,8 +21,11 @@ class ApiController {
this.router.get('/find/covers', this.findCovers.bind(this))
this.router.get('/find/:method', this.find.bind(this))
this.router.get('/audiobooks', this.getAudiobooks.bind(this))
this.router.delete('/audiobooks', this.deleteAllAudiobooks.bind(this))
this.router.post('/audiobooks/delete', this.batchDeleteAudiobooks.bind(this))
this.router.post('/audiobooks/update', this.batchUpdateAudiobooks.bind(this))
this.router.get('/audiobook/:id', this.getAudiobook.bind(this))
this.router.delete('/audiobook/:id', this.deleteAudiobook.bind(this))
this.router.patch('/audiobook/:id/tracks', this.updateAudiobookTracks.bind(this))
@@ -27,11 +34,20 @@ class ApiController {
this.router.get('/metadata/:id/:trackIndex', this.getMetadata.bind(this))
this.router.patch('/match/:id', this.match.bind(this))
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this))
this.router.patch('/user/password', this.userChangePassword.bind(this))
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
this.router.post('/authorize', this.authorize.bind(this))
this.router.get('/genres', this.getGenres.bind(this))
this.router.post('/feed', this.openRssFeed.bind(this))
this.router.get('/download/:id', this.download.bind(this))
}
find(req, res) {
@@ -39,7 +55,6 @@ class ApiController {
}
findCovers(req, res) {
console.log('Find covers', req.query)
this.scanner.findCovers(req, res)
}
@@ -57,9 +72,22 @@ class ApiController {
}
getAudiobooks(req, res) {
Logger.info('Get Audiobooks')
var audiobooksMinified = this.db.audiobooks.map(ab => ab.toJSONMinified())
res.json(audiobooksMinified)
var audiobooks = []
if (req.query.q) {
audiobooks = this.db.audiobooks.filter(ab => {
return ab.isSearchMatch(req.query.q)
}).map(ab => ab.toJSONMinified())
} else {
audiobooks = this.db.audiobooks.map(ab => ab.toJSONMinified())
}
res.json(audiobooks)
}
async deleteAllAudiobooks(req, res) {
Logger.info('Removing all Audiobooks')
var success = await this.db.recreateAudiobookDb()
if (success) res.sendStatus(200)
else res.sendStatus(500)
}
getAudiobook(req, res) {
@@ -68,10 +96,7 @@ class ApiController {
res.json(audiobook.toJSONExpanded())
}
async deleteAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
async handleDeleteAudiobook(audiobook) {
// Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
@@ -94,12 +119,66 @@ class ApiController {
}
}
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobookJSON)
}
this.emitter('audiobook_removed', audiobook.toJSONMinified())
async deleteAudiobook(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
await this.handleDeleteAudiobook(audiobook)
res.sendStatus(200)
}
async batchDeleteAudiobooks(req, res) {
var { audiobookIds } = req.body
if (!audiobookIds || !audiobookIds.length) {
return res.sendStatus(500)
}
var audiobooksToDelete = this.db.audiobooks.filter(ab => audiobookIds.includes(ab.id))
if (!audiobooksToDelete.length) {
return res.sendStatus(404)
}
for (let i = 0; i < audiobooksToDelete.length; i++) {
Logger.info(`[ApiController] Deleting Audiobook "${audiobooksToDelete[i].title}"`)
await this.handleDeleteAudiobook(audiobooksToDelete[i])
}
res.sendStatus(200)
}
async batchUpdateAudiobooks(req, res) {
var audiobooks = req.body
if (!audiobooks || !audiobooks.length) {
return res.sendStatus(500)
}
var audiobooksUpdated = 0
audiobooks = audiobooks.map((ab) => {
var _ab = this.db.audiobooks.find(__ab => __ab.id === ab.id)
if (!_ab) return null
var hasUpdated = _ab.update(ab)
if (!hasUpdated) return null
audiobooksUpdated++
return _ab
}).filter(ab => ab)
if (audiobooksUpdated) {
Logger.info(`[ApiController] ${audiobooksUpdated} Audiobooks have updates`)
for (let i = 0; i < audiobooks.length; i++) {
await this.db.updateAudiobook(audiobooks[i])
this.emitter('audiobook_updated', audiobooks[i].toJSONMinified())
}
}
res.json({
success: true,
updates: audiobooksUpdated
})
}
async updateAudiobookTracks(req, res) {
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
@@ -142,6 +221,11 @@ class ApiController {
res.sendStatus(200)
}
getUsers(req, res) {
if (req.user.type !== 'root') return res.sendStatus(403)
return res.json(this.db.users.map(u => u.toJSONForBrowser()))
}
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
await this.db.updateEntity('user', req.user)
@@ -149,6 +233,105 @@ class ApiController {
res.sendStatus(200)
}
userChangePassword(req, res) {
this.auth.userChangePassword(req, res)
}
async openRssFeed(req, res) {
var audiobookId = req.body.audiobookId
var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
if (!audiobook) return res.sendStatus(404)
var feed = await this.rssFeeds.openFeed(audiobook)
console.log('Feed open', feed)
res.json(feed)
}
async userUpdateSettings(req, res) {
var settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.sendStatus(500)
}
var madeUpdates = req.user.updateSettings(settingsUpdate)
if (madeUpdates) {
await this.db.updateEntity('user', req.user)
}
return res.json({
success: true,
settings: req.user.settings
})
}
async createUser(req, res) {
var account = req.body
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
account.pash = await this.auth.hashPass(account.password)
delete account.password
account.token = await this.auth.generateAccessToken({ userId: account.id })
account.createdAt = Date.now()
var newUser = new User(account)
var success = await this.db.insertUser(newUser)
if (success) {
this.emitter('user_added', newUser)
res.json({
user: newUser.toJSONForBrowser()
})
} else {
res.json({
error: 'Failed to save new user'
})
}
}
async deleteUser(req, res) {
if (req.params.id === 'root') {
return res.sendStatus(500)
}
if (req.user.id === req.params.id) {
Logger.error('Attempting to delete themselves...')
return res.sendStatus(500)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
Logger.error('User not found')
return res.json({
error: 'User not found'
})
}
// Todo: check if user is logged in and cancel streams
var userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id)
this.emitter('user_removed', userJson)
res.json({
success: true
})
}
async download(req, res) {
var downloadId = req.params.id
Logger.info('Download Request', downloadId)
var download = this.downloadManager.getDownload(downloadId)
if (!download) {
Logger.error('Download request not found', downloadId)
return res.sendStatus(404)
}
var options = {
headers: {
// 'Content-Disposition': `attachment; filename=${download.filename}`,
'Content-Type': download.mimeType
// 'Content-Length': download.size
}
}
Logger.info('Starting Download', options, 'SIZE', download.size)
res.download(download.fullPath, download.filename, options, (err) => {
if (err) {
Logger.error('Download Error', err)
}
})
}
getGenres(req, res) {
res.json({
genres: this.db.getGenres()
-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 Logger = require('./Logger')
class Auth {
constructor(db) {
this.db = db
@@ -42,7 +41,7 @@ class Auth {
const authHeader = req.headers['authorization']
const token = authHeader && authHeader.split(' ')[1]
if (token == null) {
Logger.error('Api called without a token')
Logger.error('Api called without a token', req.path)
return res.sendStatus(401)
}
@@ -75,6 +74,10 @@ class Auth {
verifyToken(token) {
return new Promise((resolve) => {
jwt.verify(token, process.env.TOKEN_SECRET, (err, payload) => {
if (!payload || err) {
Logger.error('JWT Verify Token Failed', err)
return resolve(null)
}
var user = this.users.find(u => u.id === payload.userId)
resolve(user || null)
})
@@ -86,7 +89,7 @@ class Auth {
var password = req.body.password || ''
Logger.debug('Check Auth', username, !!password)
var user = this.users.find(u => u.id === username)
var user = this.users.find(u => u.username === username)
if (!user) {
return res.json({ error: 'User not found' })
@@ -114,65 +117,50 @@ class Auth {
}
}
async checkAuth(req, res) {
var username = req.body.username
Logger.debug('Check Auth', username, !!req.body.password)
comparePassword(password, user) {
if (user.type === 'root' && !password && !user.pash) return true
if (!password || !user.pash) return false
return bcrypt.compare(password, user.pash)
}
var matchingUser = this.users.find(u => u.username === username)
if (!matchingUser) {
async userChangePassword(req, res) {
var { password, newPassword } = req.body
newPassword = newPassword || ''
var matchingUser = this.users.find(u => u.id === req.user.id)
// Only root can have an empty password
if (matchingUser.type !== 'root' && !newPassword) {
return res.json({
error: 'User not found'
error: 'Invalid new password - Only root can have an empty password'
})
}
var cleanedUser = { ...matchingUser }
delete cleanedUser.pash
// check for empty password (default)
if (!req.body.password) {
if (!matchingUser.pash) {
res.cookie('user', username, { signed: true })
return res.json({
user: cleanedUser
})
} else {
return res.json({
error: 'Invalid Password'
})
}
var compare = await this.comparePassword(password, matchingUser)
if (!compare) {
return res.json({
error: 'Invalid password'
})
}
// Set root password first time
if (matchingUser.type === 'root' && !matchingUser.pash && req.body.password && req.body.password.length > 1) {
console.log('Set root pash')
var pw = await this.hashPass(req.body.password)
var pw = ''
if (newPassword) {
pw = await this.hashPass(newPassword)
if (!pw) {
return res.json({
error: 'Hash failed'
})
}
this.users = this.users.map(u => {
if (u.username === matchingUser.username) {
u.pash = pw
}
return u
})
await this.saveAuthDb()
return res.json({
setroot: true,
user: cleanedUser
})
}
var compare = await bcrypt.compare(req.body.password, matchingUser.pash)
if (compare) {
res.cookie('user', username, { signed: true })
matchingUser.pash = pw
var success = await this.db.updateEntity('user', matchingUser)
if (success) {
res.json({
user: cleanedUser
success: true
})
} else {
res.json({
error: 'Invalid Password'
error: 'Unknown error'
})
}
}
-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) {
var book = await this.openLibrary.isbnLookup(isbn)
if (book.errorCode) {
console.error('Book not found')
Logger.error('Book not found')
}
return book
}
@@ -26,7 +26,17 @@ class BookFinder {
return title
}
replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
cleanTitleForCompares(title) {
if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
var stripped = this.stripSubtitle(title)
@@ -35,71 +45,98 @@ class BookFinder {
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '')
cleaned = this.replaceAccentedChars(cleaned)
return cleaned.toLowerCase()
}
cleanAuthorForCompares(author) {
if (!author) return ''
var cleaned = this.replaceAccentedChars(author)
return cleaned.toLowerCase()
}
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title)
var searchAuthor = this.cleanAuthorForCompares(author)
return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
if (author) {
b.authorDistance = levenshteinDistance(b.author || '', author)
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
// Total length of search (title or both title & author)
b.totalPossibleDistance = b.title.length
if (b.cleanedTitle.includes(searchTitle) && searchTitle.length > 4) {
b.includesSearch = searchTitle
} else if (b.title.includes(searchTitle) && searchTitle.length > 4) {
b.includesSearch = searchTitle
}
if (author) {
if (!b.author) {
b.authorDistance = author.length
} else {
b.totalPossibleDistance += b.author.length
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
if (author && b.author) b.totalPossibleDistance += b.author.length
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
// Use best distance
b.authorDistance = Math.min(cleanedAuthorDistance, authorDistance)
// Check book author contains searchAuthor
if (searchAuthor.length > 4 && b.cleanedAuthor.includes(searchAuthor)) b.includesAuthor = searchAuthor
else if (author.length > 4 && b.author.includes(author)) b.includesAuthor = author
}
}
b.totalDistance = b.titleDistance + (b.authorDistance || 0)
// Check book title contains the searchTitle
if (searchTitle.length > 4 && b.cleanedTitle.includes(searchTitle)) b.includesTitle = searchTitle
else if (title.length > 4 && b.title.includes(title)) b.includesTitle = title
return b
}).filter(b => {
if (b.includesSearch) { // If search was found in result title exactly then skip over leven distance check
Logger.debug(`Exact search was found inside title ${b.cleanedTitle}/${b.includesSearch}`)
if (b.includesTitle) { // If search title was found in result title then skip over leven distance check
Logger.debug(`Exact title was included in "${b.title}", Search: "${b.includesTitle}"`)
} else if (b.titleDistance > maxTitleDistance) {
Logger.debug(`Filtering out search result title distance = ${b.titleDistance}: "${b.cleanedTitle}"/"${searchTitle}"`)
return false
}
if (author && b.authorDistance > maxAuthorDistance) {
Logger.debug(`Filtering out search result "${b.title}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return false
if (author) {
if (b.includesAuthor) { // If search author was found in result author then skip over leven distance check
Logger.debug(`Exact author was included in "${b.author}", Search: "${b.includesAuthor}"`)
} else if (b.authorDistance > maxAuthorDistance) {
Logger.debug(`Filtering out search result "${b.author}", author distance = ${b.authorDistance}: "${b.author}"/"${author}"`)
return false
}
}
if (b.totalPossibleDistance < 4 && b.totalDistance > 0) return false
// If book total search length < 5 and was not exact match, then filter out
if (b.totalPossibleDistance < 5 && b.totalDistance > 0) return false
return true
})
}
async getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.libGen.search(title)
Logger.info(`LibGen Book Search Results: ${books.length || 0}`)
Logger.debug(`LibGen Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`LibGen Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
Logger.info(`Search has ${books.length} matches, but no close title matches`)
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
async getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance) {
var books = await this.openLibrary.searchTitle(title)
Logger.info(`OpenLib Book Search Results: ${books.length || 0}`)
Logger.debug(`OpenLib Book Search Results: ${books.length || 0}`)
if (books.errorCode) {
Logger.error(`OpenLib Search Error ${books.errorCode}`)
return []
}
var booksFiltered = this.filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance)
if (!booksFiltered.length && books.length) {
Logger.info(`Search has ${books.length} matches, but no close title matches`)
Logger.debug(`Search has ${books.length} matches, but no close title matches`)
}
return booksFiltered
}
@@ -108,7 +145,7 @@ class BookFinder {
var books = []
var maxTitleDistance = !isNaN(options.titleDistance) ? Number(options.titleDistance) : 4
var maxAuthorDistance = !isNaN(options.authorDistance) ? Number(options.authorDistance) : 4
Logger.info(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
Logger.debug(`Book Search, title: "${title}", author: "${author}", provider: ${provider}`)
if (provider === 'libgen') {
books = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
@@ -119,18 +156,16 @@ class BookFinder {
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks, olBooks)
} else {
var olBooks = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = olBooks.find(b => (b.totalDistance < 4 && b.totalPossibleDistance > 4))
if (hasCloseMatch) {
books = olBooks
} else {
Logger.info(`Book Search, LibGen has no close matches - get openlib results also`)
books = await this.getOpenLibResults(title, author, maxTitleDistance, maxAuthorDistance)
var hasCloseMatch = books.find(b => (b.totalDistance < 2 && b.totalPossibleDistance > 6))
if (!hasCloseMatch) {
Logger.debug(`Book Search, openlib has no super close matches - get libgen results also`)
var lbBooks = await this.getLibGenResults(title, author, maxTitleDistance, maxAuthorDistance)
books = books.concat(lbBooks)
}
if (!books.length && author) {
Logger.info(`Book Search, no matches for title and author.. check title only`)
if (!books.length && author && options.fallbackTitleOnly) {
Logger.debug(`Book Search, no matches for title and author.. check title only`)
return this.search(provider, title, null, options)
}
}
@@ -142,7 +177,8 @@ class BookFinder {
async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options)
console.log('Find Covers search results', searchResults)
Logger.debug(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = []
searchResults.forEach((result) => {
if (result.covers && result.covers.length) {
+27 -3
View File
@@ -1,10 +1,9 @@
const fs = require('fs-extra')
const Path = require('path')
const njodb = require("njodb")
const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./Audiobook')
const User = require('./User')
const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User')
class Db {
constructor(CONFIG_PATH) {
@@ -28,6 +27,12 @@ class Db {
return this.settingsDb
}
getEntityDbKey(entityName) {
if (entityName === 'user') return 'usersDb'
else if (entityName === 'audiobook') return 'audiobooksDb'
return 'settingsDb'
}
getEntityArrayKey(entityName) {
if (entityName === 'user') return 'users'
else if (entityName === 'audiobook') return 'audiobooks'
@@ -52,6 +57,7 @@ class Db {
pash: '',
stream: null,
token,
isActive: true,
createdAt: Date.now()
})
}
@@ -98,8 +104,10 @@ class Db {
updateAudiobook(audiobook) {
return this.audiobooksDb.update((record) => record.id === audiobook.id, () => audiobook).then((results) => {
Logger.debug(`[DB] Audiobook updated ${results.updated}`)
return true
}).catch((error) => {
Logger.error(`[DB] Audiobook update failed ${error}`)
return false
})
}
@@ -107,8 +115,10 @@ class Db {
return this.usersDb.insert([user]).then((results) => {
Logger.debug(`[DB] Inserted user ${results.inserted}`)
this.users.push(user)
return true
}).catch((error) => {
Logger.error(`[DB] Insert user Failed ${error}`)
return false
})
}
@@ -137,8 +147,10 @@ class Db {
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
})
return true
}).catch((error) => {
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
return false
})
}
@@ -155,6 +167,18 @@ class Db {
})
}
recreateAudiobookDb() {
return this.audiobooksDb.drop().then((results) => {
Logger.info(`[DB] Dropped audiobook db`, results)
this.audiobooksDb = new njodb.Database(this.AudiobooksPath)
this.audiobooks = []
return true
}).catch((error) => {
Logger.error(`[DB] Failed to drop audiobook db`, error)
return false
})
}
getGenres() {
var allGenres = []
this.db.audiobooks.forEach((audiobook) => {
+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 fullFilePath = Path.join(this.MetadataPath, streamId, req.params.file)
// development test stream - ignore
if (streamId === 'test') {
Logger.debug('Test Stream Request', streamId, req.headers, fullFilePath)
return res.sendFile(fullFilePath)
}
var exists = await fs.pathExists(fullFilePath)
if (!exists) {
Logger.warn('File path does not exist', fullFilePath)
+5
View File
@@ -33,6 +33,11 @@ class Logger {
console.info(`[${this.timestamp}] INFO:`, ...args)
}
note(...args) {
if (this.LogLevel > LOG_LEVEL.INFO) return
console.log(`[${this.timestamp}] NOTE:`, ...args)
}
warn(...args) {
if (this.LogLevel > LOG_LEVEL.WARN) return
console.warn(`[${this.timestamp}] WARN:`, ...args)
+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 BookFinder = require('./BookFinder')
const Audiobook = require('./Audiobook')
const Audiobook = require('./objects/Audiobook')
const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils')
class Scanner {
@@ -12,6 +13,8 @@ class Scanner {
this.db = db
this.emitter = emitter
this.cancelScan = false
this.bookFinder = new BookFinder()
}
@@ -19,42 +22,204 @@ class Scanner {
return this.db.audiobooks
}
async setAudiobookDataInos(audiobookData) {
for (let i = 0; i < audiobookData.length; i++) {
var abd = audiobookData[i]
var matchingAB = this.db.audiobooks.find(_ab => comparePaths(_ab.path, abd.path))
if (matchingAB) {
if (!matchingAB.ino) {
matchingAB.ino = await getIno(matchingAB.fullPath)
}
abd.ino = matchingAB.ino
} else {
abd.ino = await getIno(abd.fullPath)
if (!abd.ino) {
Logger.error('[Scanner] Invalid ino - ignoring audiobook data', abd.path)
}
}
}
return audiobookData.filter(abd => !!abd.ino)
}
async setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
var abdFile = audiobookDataAudioFiles[i]
var matchingFile = audiobookAudioFiles.find(af => comparePaths(af.path, abdFile.path))
if (matchingFile) {
if (!matchingFile.ino) {
matchingFile.ino = await getIno(matchingFile.fullPath)
}
abdFile.ino = matchingFile.ino
} else {
abdFile.ino = await getIno(abdFile.fullPath)
if (!abdFile.ino) {
Logger.error('[Scanner] Invalid abdFile ino - ignoring abd audio file', abdFile.path)
}
}
}
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
}
async scan() {
// console.log('Start scan audiobooks', this.audiobooks.map(a => a.fullPath).join(', '))
// TEMP - fix relative file paths
// TEMP - update ino for each audiobook
if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[i]
var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
// Update ino if an audio file has the same ino as the audiobook
var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
if (shouldUpdateIno) {
await ab.checkUpdateInos()
}
if (shouldUpdate) {
await this.db.updateAudiobook(ab)
}
}
}
const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
// Set ino for each ab data as a string
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
if (this.cancelScan) {
this.cancelScan = false
return null
}
var scanResults = {
removed: 0,
updated: 0,
added: 0
}
// Check for removed audiobooks
for (let i = 0; i < this.audiobooks.length; i++) {
var dataFound = audiobookDataFound.find(abd => abd.ino === this.audiobooks[i].ino)
if (!dataFound) {
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`)
var audiobookJSON = this.audiobooks[i].toJSONMinified()
await this.db.removeEntity('audiobook', this.audiobooks[i].id)
scanResults.removed++
this.emitter('audiobook_removed', audiobookJSON)
}
if (this.cancelScan) {
this.cancelScan = false
return null
}
}
for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i]
if (!audiobookData.parts.length) {
Logger.error('No Valid Parts for Audiobook', audiobookData)
} else {
var existingAudiobook = this.audiobooks.find(a => a.fullPath === audiobookData.fullPath)
if (existingAudiobook) {
Logger.info('Audiobook already added', audiobookData.title)
// Todo: Update Audiobook here
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
if (existingAudiobook) {
if (!audiobookData.audioFiles.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
scanResults.removed++
} else {
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
// Check for audio files that were removed
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
// Check for new audio files and sync existing audio files
var newAudioFiles = []
var hasUpdatedAudioFiles = false
audiobookData.audioFiles.forEach((file) => {
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
if (existingAudioFile) { // Audio file exists, sync paths
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
hasUpdatedAudioFiles = true
}
} else {
newAudioFiles.push(file)
}
})
if (newAudioFiles.length) {
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
// Scan new audio files found
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
}
if (!existingAudiobook.tracks.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
} else {
var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles
if (existingAudiobook.checkUpdateMissingParts()) {
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
hasUpdates = true
}
if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) {
hasUpdates = true
}
// Syncs path and fullPath
if (existingAudiobook.syncPaths(audiobookData)) {
hasUpdates = true
}
if (hasUpdates) {
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now()
await this.db.updateAudiobook(existingAudiobook)
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
scanResults.updated++
}
}
} // end if update existing
} else {
if (!audiobookData.audioFiles.length) {
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
} else {
// console.log('Audiobook not already there... add new audiobook', audiobookData.fullPath)
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanParts(audiobook, audiobookData.parts)
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('Invalid audiobook, no valid tracks', audiobook.title)
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
} else {
Logger.info('Audiobook Scanned', audiobook.title, `(${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
audiobook.checkUpdateMissingParts()
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified())
scanResults.added++
}
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
} // end if add new
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
scanType: 'files',
progress: {
total: audiobookDataFound.length,
done: i + 1,
progress
})
}
})
if (this.cancelScan) {
this.cancelScan = false
break
}
}
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
Logger.info(`[SCANNER] Finished ${secondsToTimestamp(scanElapsed)}`)
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
return scanResults
}
async fetchMetadata(id, trackIndex = 0) {
@@ -70,6 +235,48 @@ class Scanner {
return scanResult
}
async scanCovers() {
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
var found = 0
var notFound = 0
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
var audiobook = audiobooksNeedingCover[i]
var options = {
titleDistance: 2,
authorDistance: 2
}
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
if (results.length) {
Logger.debug(`[Scanner] Found best cover for "${audiobook.title}"`)
audiobook.book.cover = results[0]
await this.db.updateAudiobook(audiobook)
found++
this.emitter('audiobook_updated', audiobook.toJSONMinified())
} else {
notFound++
}
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
this.emitter('scan_progress', {
scanType: 'covers',
progress: {
total: audiobooksNeedingCover.length,
done: i + 1,
progress
}
})
if (this.cancelScan) {
this.cancelScan = false
break
}
}
return {
found,
notFound
}
}
async find(req, res) {
var method = req.params.method
var query = req.query
@@ -87,7 +294,10 @@ class Scanner {
async findCovers(req, res) {
var query = req.query
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null)
var options = {
fallbackTitleOnly: !!query.fallbackTitleOnly
}
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null, options)
res.json(result)
}
}
+62 -27
View File
@@ -3,7 +3,6 @@ const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const cookieparser = require('cookie-parser')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
@@ -12,15 +11,17 @@ const Db = require('./Db')
const ApiController = require('./ApiController')
const HlsController = require('./HlsController')
const StreamManager = require('./StreamManager')
const RssFeeds = require('./RssFeeds')
const DownloadManager = require('./DownloadManager')
const Logger = require('./Logger')
class Server {
constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) {
this.Port = PORT
this.Host = '0.0.0.0'
this.ConfigPath = CONFIG_PATH
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
this.ConfigPath = Path.normalize(CONFIG_PATH)
this.AudiobookPath = Path.normalize(AUDIOBOOK_PATH)
this.MetadataPath = Path.normalize(METADATA_PATH)
fs.ensureDirSync(CONFIG_PATH)
fs.ensureDirSync(METADATA_PATH)
@@ -31,7 +32,9 @@ class Server {
this.watcher = new Watcher(this.AudiobookPath)
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this))
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.emitter.bind(this))
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
this.server = null
@@ -40,6 +43,7 @@ class Server {
this.clients = {}
this.isScanning = false
this.isScanningCovers = false
this.isInitialized = false
}
@@ -51,34 +55,43 @@ class Server {
}
emitter(ev, data) {
Logger.debug('EMITTER', ev)
if (!this.io) {
Logger.error('Invalid IO')
return
}
// Logger.debug('EMITTER', ev)
this.io.emit(ev, data)
}
async fileAddedUpdated({ path, fullPath }) {
Logger.info('[SERVER] FileAddedUpdated', path, fullPath)
}
async fileAddedUpdated({ path, fullPath }) { }
async fileRemoved({ path, fullPath }) { }
async scan() {
Logger.info('[SERVER] Starting Scan')
Logger.info('[Server] Starting Scan')
this.isScanning = true
this.isInitialized = true
this.emitter('scan_start')
await this.scanner.scan()
this.emitter('scan_start', 'files')
var results = await this.scanner.scan()
this.isScanning = false
this.emitter('scan_complete')
Logger.info('[SERVER] Scan complete')
this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[Server] Scan complete')
}
async scanCovers() {
Logger.info('[Server] Start cover scan')
this.isScanningCovers = true
this.emitter('scan_start', 'covers')
var results = await this.scanner.scanCovers()
this.isScanningCovers = false
this.emitter('scan_complete', { scanType: 'covers', results })
Logger.info('[Server] Cover scan complete')
}
cancelScan() {
if (!this.isScanningCovers && !this.isScanning) return
this.scanner.cancelScan = true
}
async init() {
Logger.info('[SERVER] Init')
Logger.info('[Server] Init')
await this.streamManager.removeOrphanStreams()
await this.downloadManager.removeOrphanDownloads()
await this.db.init()
this.auth.init()
@@ -101,25 +114,29 @@ class Server {
this.server = http.createServer(app)
app.use(cookieparser('secret_family_recipe'))
app.use(this.auth.cors)
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
if (process.env.NODE_ENV === 'production') {
const distPath = Path.join(global.appRoot, '/client/dist')
app.use(express.static(distPath))
app.use('/local', express.static(this.AudiobookPath))
} else {
app.use(express.static(this.AudiobookPath))
}
app.use(express.static(this.AudiobookPath))
app.use(express.static(this.MetadataPath))
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use(express.urlencoded({ extended: true }));
app.use(express.json())
// Dynamic routes are not generated on client
app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
app.get('/', (req, res) => {
res.sendFile('/index.html')
})
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router)
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
@@ -128,6 +145,21 @@ class Server {
res.json({ success: true })
})
// Used in development to set-up streams without authentication
if (process.env.NODE_ENV !== 'production') {
app.use('/test-hls', this.hlsController.router)
app.get('/test-stream/:id', async (req, res) => {
var uri = await this.streamManager.openTestStream(this.MetadataPath, req.params.id)
res.send(uri)
})
app.get('/catalog.json', (req, res) => {
Logger.error('Catalog request made', req.headers)
res.json()
})
}
this.server.listen(this.Port, this.Host, () => {
Logger.info(`Running on http://${this.Host}:${this.Port}`)
})
@@ -151,9 +183,12 @@ class Server {
socket.on('auth', (token) => this.authenticateSocket(socket, token))
socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
+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 fs = require('fs-extra')
const Path = require('path')
@@ -90,7 +91,8 @@ class StreamManager {
Logger.info('Close Stream Request', socket.id)
var client = socket.sheepClient
if (!client || !client.stream) {
Logger.error('No stream for client', client.user.id)
Logger.error('No stream for client', (client && client.user) ? client.user.username : 'No Client')
client.socket.emit('stream_closed', 'n/a')
return
}
// var streamId = client.stream.id
@@ -100,6 +102,23 @@ class StreamManager {
this.db.updateUserStream(client.user.id, null)
}
async openTestStream(streamPath, audiobookId) {
Logger.info('Open Stream Test Request', audiobookId)
var audiobook = this.audiobooks.find(ab => ab.id === audiobookId)
var stream = new StreamTest(streamPath, audiobook)
stream.on('closed', () => {
console.log('Stream closed')
})
var playlistUri = await stream.generatePlaylist()
stream.start()
Logger.info('Stream Playlist', playlistUri)
Logger.info('Test Stream Opened for audiobook', audiobook.title, 'with streamId', stream.id)
return playlistUri
}
streamUpdate(socket, { currentTime, streamId }) {
var client = socket.sheepClient
if (!client || !client.stream) {
+19 -14
View File
@@ -1,6 +1,6 @@
var EventEmitter = require('events')
var Logger = require('./Logger')
var chokidar = require('chokidar')
var Watcher = require('watcher')
class FolderWatcher extends EventEmitter {
constructor(audiobookPath) {
@@ -12,15 +12,14 @@ class FolderWatcher extends EventEmitter {
initWatcher() {
try {
Logger.info('[WATCHER] Initializing..')
this.watcher = chokidar.watch(this.AudiobookPath, {
ignoreInitial: true,
Logger.info('[FolderWatcher] Initializing..')
this.watcher = new Watcher(this.AudiobookPath, {
ignored: /(^|[\/\\])\../, // ignore dotfiles
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 2500,
pollInterval: 500
}
renameDetection: true,
renameTimeout: 2000,
recursive: true,
ignoreInitial: true,
persistent: true
})
this.watcher
.on('add', (path) => {
@@ -29,10 +28,12 @@ class FolderWatcher extends EventEmitter {
this.onFileUpdated(path)
}).on('unlink', path => {
this.onFileRemoved(path)
}).on('rename', (path, pathNext) => {
this.onRename(path, pathNext)
}).on('error', (error) => {
Logger.error(`Watcher error: ${error}`)
Logger.error(`[FolderWatcher] ${error}`)
}).on('ready', () => {
Logger.info('[WATCHER] Ready')
Logger.info('[FolderWatcher] Ready')
})
} catch (error) {
Logger.error('Chokidar watcher failed', error)
@@ -45,7 +46,7 @@ class FolderWatcher extends EventEmitter {
}
onNewFile(path) {
Logger.info('FolderWatcher: New File', path)
Logger.debug('FolderWatcher: New File', path)
this.emit('file_added', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
@@ -53,7 +54,7 @@ class FolderWatcher extends EventEmitter {
}
onFileRemoved(path) {
Logger.info('FolderWatcher: File Removed', path)
Logger.debug('[FolderWatcher] File Removed', path)
this.emit('file_removed', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
@@ -61,11 +62,15 @@ class FolderWatcher extends EventEmitter {
}
onFileUpdated(path) {
Logger.info('FolderWatcher: Updated File', path)
Logger.debug('[FolderWatcher] Updated File', path)
this.emit('file_updated', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
}
onRename(pathFrom, pathTo) {
Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`)
}
}
module.exports = FolderWatcher
+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 {
constructor(audioTrack = null) {
this.index = null
this.ino = null
this.path = null
this.fullPath = null
this.ext = null
@@ -31,6 +33,8 @@ class AudioTrack {
construct(audioTrack) {
this.index = audioTrack.index
this.ino = audioTrack.ino || null
this.path = audioTrack.path
this.fullPath = audioTrack.fullPath
this.ext = audioTrack.ext
@@ -45,6 +49,12 @@ class AudioTrack {
this.timeBase = audioTrack.timeBase
this.channels = audioTrack.channels
this.channelLayout = audioTrack.channelLayout
this.tagAlbum = audioTrack.tagAlbum
this.tagArtist = audioTrack.tagArtist
this.tagGenre = audioTrack.tagGenre
this.tagTitle = audioTrack.tagTitle
this.tagTrack = audioTrack.tagTrack
}
get name() {
@@ -54,6 +64,7 @@ class AudioTrack {
toJSON() {
return {
index: this.index,
ino: this.ino,
path: this.path,
fullPath: this.fullPath,
ext: this.ext,
@@ -65,12 +76,19 @@ class AudioTrack {
language: this.language,
timeBase: this.timeBase,
channels: this.channels,
channelLayout: this.channelLayout
channelLayout: this.channelLayout,
tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist,
tagGenre: this.tagGenre,
tagTitle: this.tagTitle,
tagTrack: this.tagTrack
}
}
setData(probeData) {
this.index = probeData.index
this.ino = probeData.ino || null
this.path = probeData.path
this.fullPath = probeData.fullPath
this.ext = probeData.ext
@@ -92,5 +110,17 @@ class AudioTrack {
this.tagTitle = probeData.file_tag_title || null
this.tagTrack = probeData.file_tag_track || null
}
syncFile(newFile) {
var hasUpdates = false
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
keysToSync.forEach((key) => {
if (newFile[key] !== undefined && newFile[key] !== this[key]) {
hasUpdates = true
this[key] = newFile[key]
}
})
return hasUpdates
}
}
module.exports = AudioTrack
+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 Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const { secondsToTimestamp } = require('./utils/fileUtils')
const hlsPlaylistGenerator = require('./utils/hlsPlaylistGenerator')
const Logger = require('../Logger')
const { secondsToTimestamp } = require('../utils/fileUtils')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
class Stream extends EventEmitter {
constructor(streamPath, client, audiobook) {
@@ -19,7 +20,7 @@ class Stream extends EventEmitter {
this.streamPath = Path.join(streamPath, this.id)
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
this.fakePlaylistPath = Path.join(this.streamPath, 'fake-output.m3u8')
this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')
this.startTime = 0
this.ffmpeg = null
@@ -129,7 +130,6 @@ class Stream extends EventEmitter {
async generatePlaylist() {
fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength)
console.log('Playlist generated')
return this.clientPlaylistUri
}
@@ -212,29 +212,12 @@ class Stream extends EventEmitter {
}, 2000)
}
escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
return path.replace(/\\/g, '/').replace(/ /g, '\\ ').replace(/'/g, '\\\'')
}
async start() {
Logger.info(`[STREAM] START STREAM - Num Segments: ${this.numSegments}`)
this.ffmpeg = Ffmpeg()
var currTrackEnd = 0
var startingTrack = this.tracks.find(t => {
currTrackEnd += t.duration
return this.startTime < currTrackEnd
})
var trackStartTime = currTrackEnd - startingTrack.duration
var tracksToInclude = this.tracks.filter(t => t.index >= startingTrack.index)
var trackPaths = tracksToInclude.map(t => {
var line = 'file ' + this.escapeSingleQuotes(t.fullPath) + '\n' + `duration ${t.duration}`
return line
})
var inputstr = trackPaths.join('\n\n')
await fs.writeFile(this.concatFilesPath, inputstr)
var trackStartTime = await writeConcatFile(this.tracks, this.concatFilesPath, this.startTime)
this.ffmpeg.addInput(this.concatFilesPath)
this.ffmpeg.inputFormat('concat')
@@ -267,7 +250,7 @@ class Stream extends EventEmitter {
])
var segmentFilename = Path.join(this.streamPath, this.segmentBasename)
this.ffmpeg.addOption(`-hls_segment_filename ${segmentFilename}`)
this.ffmpeg.output(this.fakePlaylistPath)
this.ffmpeg.output(this.finalPlaylistPath)
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
+48 -4
View File
@@ -6,14 +6,26 @@ class User {
this.type = null
this.stream = null
this.token = null
this.isActive = true
this.createdAt = null
this.audiobooks = null
this.settings = {}
if (user) {
this.construct(user)
}
}
getDefaultUserSettings() {
return {
orderBy: 'book.title',
orderDesc: false,
filterBy: 'all',
playbackRate: 1,
bookshelfCoverSize: 120
}
}
toJSON() {
return {
id: this.id,
@@ -23,7 +35,9 @@ class User {
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
createdAt: this.createdAt
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
}
}
@@ -35,7 +49,9 @@ class User {
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
createdAt: this.createdAt
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
}
}
@@ -44,10 +60,12 @@ class User {
this.username = user.username
this.pash = user.pash
this.type = user.type
this.stream = user.stream
this.stream = user.stream || null
this.token = user.token
this.audiobooks = user.audiobooks || null
this.createdAt = user.createdAt
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings()
}
updateAudiobookProgress(stream) {
@@ -64,6 +82,32 @@ class User {
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
}
// Returns Boolean If update was made
updateSettings(settings) {
if (!this.settings) {
this.settings = { ...settings }
return true
}
var madeUpdates = false
for (const key in this.settings) {
if (settings[key] !== undefined && this.settings[key] !== settings[key]) {
this.settings[key] = settings[key]
madeUpdates = true
}
}
// Check if new settings update has keys not currently in user settings
for (const key in settings) {
if (settings[key] !== undefined && this.settings[key] === undefined) {
this.settings[key] = settings[key]
madeUpdates = true
}
}
return madeUpdates
}
resetAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false
+6
View File
@@ -26,6 +26,12 @@ class OpenLibrary {
async getWorksData(worksKey) {
var worksData = await this.get(`${worksKey}.json`)
if (!worksData) {
return {
errorMsg: 'Works Data Request failed',
errorCode: 500
}
}
if (!worksData.covers) worksData.covers = []
var coverImages = worksData.covers.filter(c => c > 0).map(c => `https://covers.openlibrary.org/b/id/${c}-L.jpg`)
var description = null
+31 -45
View File
@@ -1,7 +1,6 @@
const Path = require('path')
const Logger = require('../Logger')
var prober = require('./prober')
const prober = require('./prober')
function getDefaultAudioStream(audioStreams) {
if (audioStreams.length === 1) return audioStreams[0]
@@ -76,60 +75,61 @@ function getTrackNumberFromFilename(filename) {
return number
}
async function scanParts(audiobook, parts) {
if (!parts || !parts.length) {
Logger.error('Scan Parts', audiobook.title, 'No Parts', parts)
async function scanAudioFiles(audiobook, newAudioFiles) {
if (!newAudioFiles || !newAudioFiles.length) {
Logger.error('[AudioFileScanner] Scan Audio Files no files', audiobook.title)
return
}
var tracks = []
for (let i = 0; i < parts.length; i++) {
var fullPath = Path.join(audiobook.fullPath, parts[i])
for (let i = 0; i < newAudioFiles.length; i++) {
var audioFile = newAudioFiles[i]
var scanData = await scan(fullPath)
var scanData = await scan(audioFile.fullPath)
if (!scanData || scanData.error) {
Logger.error('Scan failed for', parts[i])
audiobook.invalidParts.push(parts[i])
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
// audiobook.invalidAudioFiles.push(parts[i])
continue;
}
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var trackNumFromFilename = getTrackNumberFromFilename(parts[i])
var trackNumFromFilename = getTrackNumberFromFilename(audioFile.filename)
var audioFileObj = {
path: Path.join(audiobook.path, parts[i]),
ext: Path.extname(parts[i]),
filename: parts[i],
fullPath: fullPath,
ino: audioFile.ino,
filename: audioFile.filename,
path: audioFile.path,
fullPath: audioFile.fullPath,
ext: audioFile.ext,
...scanData,
trackNumFromMeta,
trackNumFromFilename
}
audiobook.audioFiles.push(audioFileObj)
var audioFile = audiobook.addAudioFile(audioFileObj)
var trackNumber = 1
if (parts.length > 1) {
if (newAudioFiles.length > 1) {
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
if (trackNumber === null) {
Logger.error('Invalid track number for', parts[i])
audioFileObj.invalid = true
audioFileObj.error = 'Failed to get track number'
Logger.error('[AudioFileScanner] Invalid track number for', audioFile.filename)
audioFile.invalid = true
audioFile.error = 'Failed to get track number'
continue;
}
}
if (tracks.find(t => t.index === trackNumber)) {
Logger.error('Duplicate track number for', parts[i])
audioFileObj.invalid = true
audioFileObj.error = 'Duplicate track number'
Logger.error('[AudioFileScanner] Duplicate track number for', audioFile.filename)
audioFile.invalid = true
audioFile.error = 'Duplicate track number'
continue;
}
audioFileObj.index = trackNumber
tracks.push(audioFileObj)
audioFile.index = trackNumber
tracks.push(audioFile)
}
if (!tracks.length) {
Logger.warn('No Tracks for audiobook', audiobook.id)
Logger.warn('[AudioFileScanner] No Tracks for audiobook', audiobook.id)
return
}
@@ -148,26 +148,12 @@ async function scanParts(audiobook, parts) {
})
}
var parts_copy = tracks.map(p => ({ ...p }))
var current_index = 1
for (let i = 0; i < parts_copy.length; i++) {
var cleaned_part = parts_copy[i]
if (cleaned_part.index > current_index) {
var num_parts_missing = cleaned_part.index - current_index
for (let x = 0; x < num_parts_missing; x++) {
audiobook.missingParts.push(current_index + x)
}
}
current_index = cleaned_part.index + 1
}
if (audiobook.missingParts.length) {
Logger.info('Audiobook', audiobook.title, 'Has missing parts', audiobook.missingParts)
}
var hasTracksAlready = audiobook.tracks.length
tracks.forEach((track) => {
audiobook.addTrack(track)
})
if (hasTracksAlready) {
audiobook.tracks.sort((a, b) => a.index - b.index)
}
}
module.exports.scanParts = scanParts
module.exports.scanAudioFiles = scanAudioFiles
+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
async function getFileSize(path) {
var stat = await getFileStat(path)
if (!stat) return 0
return stat.size || 0
}
module.exports.getFileSize = getFileSize
function bytesPretty(bytes, decimals = 0) {
if (bytes === 0) {
return '0 Bytes'
+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) => {
if (!caseSensitive) {
str1 = str1.toLowerCase()
@@ -23,4 +27,36 @@ const levenshteinDistance = (str1, str2, caseSensitive = false) => {
}
return track[str2.length][str1.length];
}
module.exports.levenshteinDistance = levenshteinDistance
module.exports.levenshteinDistance = levenshteinDistance
const cleanString = (str) => {
if (!str) return ''
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
var cleaned = ''
for (let i = 0; i < str.length; i++) {
cleaned += cleanChar(str[i])
}
return cleaned
}
module.exports.cleanString = cleanString
module.exports.isObject = (val) => {
return val !== null && typeof val === 'object'
}
module.exports.comparePaths = (path1, path2) => {
return path1 === path2 || Path.normalize(path1) === Path.normalize(path2)
}
module.exports.getIno = (path) => {
return fs.promises.stat(path, { bigint: true }).then((data => String(data.ino))).catch((err) => {
Logger.error('[Utils] Failed to get ino for path', path, error)
return null
})
}
+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 dir = require('node-dir')
const Logger = require('../Logger')
const { cleanString } = require('./index')
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
const INFO_FORMATS = ['nfo']
const IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'webp']
const EBOOK_FORMATS = ['epub', 'pdf']
@@ -22,7 +23,7 @@ function getPaths(path) {
function getFileType(ext) {
var ext_cleaned = ext.toLowerCase()
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
if (AUDIOBOOK_PARTS_FORMATS.includes(ext_cleaned)) return 'abpart'
if (AUDIO_FORMATS.includes(ext_cleaned)) return 'audio'
if (INFO_FORMATS.includes(ext_cleaned)) return 'info'
if (IMAGE_FORMATS.includes(ext_cleaned)) return 'image'
if (EBOOK_FORMATS.includes(ext_cleaned)) return 'ebook'
@@ -34,17 +35,19 @@ async function getAllAudiobookFiles(abRootPath) {
var audiobooks = {}
paths.files.forEach((filepath) => {
var relpath = filepath.replace(abRootPath, '').slice(1)
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
var pathformat = Path.parse(relpath)
var path = pathformat.dir
// If relative file directory has 3 folders, then the middle folder will be series
var splitDir = pathformat.dir.split(Path.sep)
if (splitDir.length === 1) {
Logger.error('Invalid file in root dir', filepath)
if (!path) {
Logger.error('Ignoring file in root dir', filepath)
return
}
var author = splitDir.shift()
// If relative file directory has 3 folders, then the middle folder will be series
var splitDir = pathformat.dir.split(Path.sep)
var author = null
if (splitDir.length > 1) author = splitDir.shift()
var series = null
if (splitDir.length > 1) series = splitDir.shift()
var title = splitDir.shift()
@@ -64,26 +67,24 @@ async function getAllAudiobookFiles(abRootPath) {
audiobooks[path] = {
author: author,
title: title,
series: series,
series: cleanString(series),
publishYear: publishYear,
path: relpath,
path: path,
fullPath: Path.join(abRootPath, path),
parts: [],
audioFiles: [],
otherFiles: []
}
}
var filetype = getFileType(pathformat.ext)
if (filetype === 'abpart') {
audiobooks[path].parts.push(pathformat.base)
var fileObj = {
filetype: getFileType(pathformat.ext),
filename: pathformat.base,
path: relpath,
fullPath: filepath,
ext: pathformat.ext
}
if (fileObj.filetype === 'audio') {
audiobooks[path].audioFiles.push(fileObj)
} else {
var fileObj = {
filetype: filetype,
filename: pathformat.base,
path: relpath,
fullPath: filepath,
ext: pathformat.ext
}
audiobooks[path].otherFiles.push(fileObj)
}
})
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB