Compare commits

..

22 Commits

Author SHA1 Message Date
Mark Cooper db01db3a2b Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
Mark Cooper 0851a1e71e Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
Mark Cooper 0addfc8269 Readme upcoming features update 2021-09-16 08:44:39 -05:00
Mark Cooper b2ab5730f5 Add batch read/not read update, Update tooltip positions 2021-09-16 08:37:09 -05:00
Mark Cooper 7859d7a502 Add version checker 2021-09-15 17:59:38 -05:00
Mark Cooper 174fce9614 Adding download zip file, fix local cover art for m4b download 2021-09-14 20:45:00 -05:00
Mark Cooper 3dfd7ea035 Add audiobook uploader 2021-09-13 20:18:58 -05:00
Mark Cooper 6cb253598b Update user progress reset and delete logic 2021-09-12 18:22:52 -05:00
Mark Cooper 11f4caffa8 Add socket event to remove download, fix clearInterval on stream loop 2021-09-12 16:10:12 -05:00
Mark Cooper 80f90907d4 Update merge for m4b files, add progress event for local audiobooks 2021-09-11 19:59:48 -05:00
Mark Cooper 4e92ea3992 Update scanner v3, add isActive support for users 2021-09-10 19:55:02 -05:00
Mark Cooper ddbf678a8b Merge tracks with codec copy' 2021-09-09 05:10:55 -05:00
Mark Cooper 315de87bfc Adding chapters and downloading m4b file 2021-09-08 09:15:54 -05:00
Mark Cooper 26d922d3dc Auto add/update/remove audiobooks, update screenshots 2021-09-06 20:14:04 -05:00
Mark Cooper ee452d41ee Adding permissions per user, add volume number sort 2021-09-06 17:42:15 -05:00
Mark Cooper 1d7d2a1dac Fix details tab save 2021-09-06 16:11:37 -05:00
Mark Cooper 41c391e87b Update user audiobook progress model, add mark as read/not read, download individual tracks 2021-09-06 14:13:01 -05:00
Mark Cooper 1f2afe4d92 Editing accounts, change root account username, removed token expiration 2021-09-05 18:20:29 -05:00
Mark Cooper e534d015be Allow any utf-8 char in genre and tags, fix stream manager user undefined 2021-09-05 14:30:33 -05:00
Mark Cooper d2a2f3ff6a New filters using base64 strings, keyword filter 2021-09-05 13:21:02 -05:00
Mark Cooper af05e78cdf Add Subtitle and Narrarator fields, add server settings object, scanner to parse out subtitles 2021-09-04 19:58:39 -05:00
Mark Cooper e566c6c9d5 Improve track order detection, allow for excluding audio files from tracklist 2021-09-04 18:02:42 -05:00
83 changed files with 3415 additions and 750 deletions
+9 -1
View File
@@ -83,6 +83,14 @@
box-shadow: 2px 8px 6px #111111aa;
}
.box-shadow-sm-up {
box-shadow: 0px -5px 8px #11111122;
}
.box-shadow-md-up {
box-shadow: 0px -8px 8px #11111144;
}
.box-shadow-lg-up {
box-shadow: 0px -12px 8px #111111ee;
}
@@ -93,4 +101,4 @@
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
}
+29 -7
View File
@@ -8,7 +8,16 @@
<p class="font-mono text-sm">{{ totalDurationPretty }}</p>
</div>
<div class="absolute right-24 top-0 bottom-0">
<div class="absolute right-20 top-0 bottom-0">
<div v-if="chapters.length" class="cursor-pointer flex items-center justify-center text-gray-300" @mousedown.prevent @mouseup.prevent @click.stop="showChapters">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
<div v-else class="flex items-center justify-center text-gray-500">
<span class="material-icons text-3xl">format_list_bulleted</span>
</div>
</div>
<div class="absolute right-32 top-0 bottom-0">
<controls-volume-control v-model="volume" @input="updateVolume" />
</div>
<div class="flex my-2">
@@ -58,6 +67,8 @@
</div>
<audio ref="audio" @pause="paused" @playing="playing" @progress="progress" @timeupdate="timeupdate" @loadeddata="audioLoadedData" />
<modals-chapters-modal v-model="showChaptersModal" :chapters="chapters" @select="selectChapter" />
</div>
</template>
@@ -66,7 +77,11 @@ import Hls from 'hls.js'
export default {
props: {
loading: Boolean
loading: Boolean,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {
@@ -84,7 +99,8 @@ export default {
audioEl: null,
totalDuration: 0,
seekedTime: 0,
seekLoading: false
seekLoading: false,
showChaptersModal: false
}
},
computed: {
@@ -96,6 +112,10 @@ export default {
}
},
methods: {
selectChapter(chapter) {
this.seek(chapter.start)
this.showChaptersModal = false
},
seek(time) {
if (this.loading) {
return
@@ -110,7 +130,7 @@ export default {
}
this.seekedTime = time
this.seekLoading = true
console.warn('SEEK TO', this.$secondsToTimestamp(time))
this.audioEl.currentTime = time
if (this.$refs.playedTrack) {
@@ -361,7 +381,7 @@ export default {
xhr.setRequestHeader('Authorization', `Bearer ${this.token}`)
}
}
console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
// console.log('[AudioPlayer-Set] HLS Config', hlsOptions)
this.hlsInstance = new Hls(hlsOptions)
var audio = this.$refs.audio
audio.volume = this.volume
@@ -386,10 +406,13 @@ export default {
}
})
this.hlsInstance.on(Hls.Events.DESTROYING, () => {
console.warn('[HLS] Destroying HLS Instance')
console.log('[HLS] Destroying HLS Instance')
})
})
},
showChapters() {
this.showChaptersModal = true
},
play() {
if (!this.$refs.audio) {
console.error('No Audio ref')
@@ -410,7 +433,6 @@ export default {
this.staleHlsInstance = this.hlsInstance
this.staleHlsInstance.destroy()
this.hlsInstance = null
console.log('Terminated HLS Instance', this.staleHlsInstance)
}
},
async resetStream(startTime) {
+71 -29
View File
@@ -11,11 +11,27 @@
<controls-global-search />
<div class="flex-grow" />
<!-- <a v-if="isUpdateAvailable" :href="githubTagUrl" target="_blank" class="flex items-center rounded-full bg-warning p-2 text-sm">
<span class="material-icons">notification_important</span>
<span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span>
</a> -->
<nuxt-link v-if="isRootUser" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mr-4">
<span class="material-icons">upload</span>
</nuxt-link>
<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" />
<nuxt-link to="/account" class="relative w-32 bg-fg border border-gray-500 rounded shadow-sm ml-5 pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer hover:bg-bg hover:bg-opacity-40" aria-haspopup="listbox" aria-expanded="true">
<span class="flex items-center">
<span class="block truncate">{{ username }}</span>
</span>
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<span class="material-icons text-gray-100">person</span>
</span>
</nuxt-link>
</div>
<div v-show="numAudiobooksSelected" class="absolute top-0 left-0 w-full h-full px-4 bg-primary flex items-center">
@@ -23,8 +39,16 @@
<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>
<ui-tooltip :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :disabled="processingBatch" :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" />
</ui-tooltip>
<template v-if="userCanUpdate">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
<!-- <ui-btn v-show="!processingBatchDelete" color="warning" small class="mx-2 w-10 h-10" :padding-y="0" :padding-x="0" @click="batchEditClick"><span class="material-icons text-gray-200 text-base">edit</span></ui-btn> -->
</template>
<ui-icon-btn v-show="userCanDelete" :disabled="processingBatchDelete" icon="delete" bg-color="error" class="mx-1.5" @click="batchDeleteClick" />
<!-- <ui-btn v-if="userCanDelete" 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>
@@ -35,17 +59,6 @@
export default {
data() {
return {
menuItems: [
{
value: 'account',
text: 'Account',
to: '/account'
},
{
value: 'logout',
text: 'Logout'
}
],
processingBatchDelete: false
}
},
@@ -71,8 +84,27 @@ export default {
isAllSelected() {
return this.audiobooksShowing.length === this.selectedAudiobooks.length
},
userAudiobooks() {
return this.$store.state.user.user.audiobooks || {}
},
audiobooksShowing() {
return this.$store.getters['audiobooks/getFiltered']()
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead
})
},
processingBatch() {
return this.$store.state.processingBatch
}
},
methods: {
@@ -83,20 +115,6 @@ export default {
this.$router.push('/')
}
},
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
menuAction(action) {
if (action === 'logout') {
this.logout()
}
},
cancelSelectionMode() {
if (this.processingBatchDelete) return
this.$store.commit('setSelectedAudiobooks', [])
@@ -109,8 +127,32 @@ export default {
this.$store.commit('setSelectedAudiobooks', audiobookIds)
}
},
toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
return {
audiobookId: ab,
isRead: newIsRead
}
})
this.$axios
.patch(`/api/user/audiobooks`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.$store.commit('setProcessingBatch', false)
this.$store.commit('setSelectedAudiobooks', [])
})
.catch((error) => {
this.$toast.error('Batch update failed')
console.error('Failed to batch update read/not read', error)
this.$store.commit('setProcessingBatch', false)
})
},
batchDeleteClick() {
if (confirm(`Are you sure you want to delete these ${this.numAudiobooksSelected} audiobook(s)?`)) {
var audiobookText = this.numAudiobooksSelected > 1 ? `these ${this.numAudiobooksSelected} audiobooks` : 'this audiobook'
var confirmMsg = `Are you sure you want to remove ${audiobookText}?\n\n*Does not delete your files, only removes the audiobooks from AudioBookshelf`
if (confirm(confirmMsg)) {
this.processingBatchDelete = true
this.$store.commit('setProcessingBatch', true)
this.$axios
+34 -2
View File
@@ -13,7 +13,7 @@
<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>
</div>
<div class="w-full flex flex-col items-center">
<div v-else class="w-full flex flex-col items-center">
<template v-for="(shelf, index) in groupedBooks">
<div :key="index" class="w-full bookshelfRow relative">
<div class="flex justify-center items-center">
@@ -24,6 +24,10 @@
<div class="bookshelfDivider h-4 w-full absolute bottom-0 left-0 right-0 z-10" />
</div>
</template>
<div v-show="!groupedBooks.length" class="w-full py-16 text-center text-xl">
<div class="py-4">No Audiobooks</div>
<ui-btn v-if="filterBy !== 'all' || keywordFilter" @click="clearFilter">Clear Filter</ui-btn>
</div>
</div>
</div>
</template>
@@ -38,10 +42,19 @@ export default {
currFilterOrderKey: null,
availableSizes: [60, 80, 100, 120, 140, 160, 180, 200, 220],
selectedSizeIndex: 3,
rowPaddingX: 40
rowPaddingX: 40,
keywordFilterTimeout: null
}
},
watch: {
keywordFilter() {
this.checkKeywordFilter()
}
},
computed: {
keywordFilter() {
return this.$store.state.audiobooks.keywordFilter
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
},
@@ -65,9 +78,28 @@ export default {
},
isSelectionMode() {
return this.$store.getters['getNumAudiobooksSelected']
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
}
},
methods: {
clearFilter() {
this.$store.commit('audiobooks/setKeywordFilter', null)
if (this.filterBy !== 'all') {
this.$store.dispatch('user/updateUserSettings', {
filterBy: 'all'
})
} else {
this.setGroupedBooks()
}
},
checkKeywordFilter() {
clearTimeout(this.keywordFilterTimeout)
this.keywordFilterTimeout = setTimeout(() => {
this.setGroupedBooks()
}, 500)
},
increaseSize() {
this.selectedSizeIndex = Math.min(this.availableSizes.length - 1, this.selectedSizeIndex + 1)
this.resize()
+14 -3
View File
@@ -3,9 +3,12 @@
<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-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-48 h-7.5" @change="updateOrder" />
<ui-text-input v-model="_keywordFilter" placeholder="Keyword Filter" :padding-y="1.5" class="text-xs w-40" />
<controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5 ml-4" @change="updateFilter" />
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5 ml-4" @change="updateOrder" />
</div>
</div>
</template>
@@ -21,6 +24,14 @@ export default {
computed: {
numShowing() {
return this.$store.getters['audiobooks/getFiltered']().length
},
_keywordFilter: {
get() {
return this.$store.state.audiobooks.keywordFilter
},
set(val) {
this.$store.commit('audiobooks/setKeywordFilter', val)
}
}
},
methods: {
+6 -3
View File
@@ -14,7 +14,7 @@
<span v-if="stream" class="material-icons px-4 cursor-pointer" @click="cancelStream">close</span>
</div>
<audio-player ref="audioPlayer" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
<audio-player ref="audioPlayer" :chapters="chapters" :loading="isLoading" @updateTime="updateTime" @hook:mounted="audioPlayerMounted" />
</div>
</template>
@@ -49,6 +49,9 @@ export default {
book() {
return this.streamAudiobook ? this.streamAudiobook.book || {} : {}
},
chapters() {
return this.streamAudiobook ? this.streamAudiobook.chapters || [] : []
},
title() {
return this.book.title || 'No Title'
},
@@ -94,7 +97,7 @@ export default {
streamProgress(data) {
if (!data.numSegments) return
var chunks = data.chunks
console.log(`[STREAM-CONTAINER] Stream Progress ${data.percent}`)
console.log(`[StreamContainer] Stream Progress ${data.percent}`)
if (this.$refs.audioPlayer) {
this.$refs.audioPlayer.setChunksReady(chunks, data.numSegments)
} else {
@@ -104,7 +107,7 @@ export default {
streamOpen(stream) {
this.stream = stream
if (this.$refs.audioPlayer) {
console.log('[STREAM-CONTAINER] streamOpen', stream)
console.log('[StreamContainer] streamOpen', stream)
this.openStream()
} else if (this.audioPlayerReady) {
console.error('No Audio Ref')
+20 -4
View File
@@ -14,19 +14,22 @@
<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 v-show="!isSelectionMode && !isMissing" 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">
<div v-if="userCanUpdate" 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 v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<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">
@@ -125,8 +128,14 @@ export default {
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
},
isMissing() {
return this.audiobook.isMissing
},
hasMissingParts() {
return this.audiobook.hasMissingParts
@@ -135,6 +144,7 @@ export default {
return this.audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
@@ -153,6 +163,12 @@ export default {
classes.push('border-2 border-yellow-400')
}
return classes
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {
+18 -14
View File
@@ -42,9 +42,9 @@
</div>
</li>
<template v-for="item in sublistItems">
<li :key="item" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item)">
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
<div class="flex items-center">
<span class="font-normal truncate py-2 text-xs">{{ snakeToNormal(item) }}</span>
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
</div>
</li>
</template>
@@ -81,6 +81,11 @@ export default {
text: 'Series',
value: 'series',
sublist: true
},
{
text: 'Authors',
value: 'authors',
sublist: true
}
]
}
@@ -109,14 +114,14 @@ export default {
if (!this.selected) return ''
var parts = this.selected.split('.')
if (parts.length > 1) {
return this.snakeToNormal(parts[1])
return this.$decode(parts[1])
}
var _sel = this.items.find((i) => i.value === this.selected)
if (!_sel) return ''
return _sel.text
},
genres() {
return this.$store.state.audiobooks.genres
return this.$store.getters['audiobooks/getGenresUsed']
},
tags() {
return this.$store.state.audiobooks.tags
@@ -124,8 +129,16 @@ export default {
series() {
return this.$store.state.audiobooks.series
},
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
sublistItems() {
return this[this.sublist] || []
return (this[this.sublist] || []).map((item) => {
return {
text: item,
value: this.$encode(item)
}
})
}
},
methods: {
@@ -134,15 +147,6 @@ export default {
this.showMenu = false
this.$nextTick(() => this.$emit('change', 'all'))
},
snakeToNormal(kebab) {
if (!kebab) {
return 'err'
}
return String(kebab)
.split('_')
.map((t) => t.slice(0, 1).toUpperCase() + t.slice(1))
.join(' ')
},
clickOutside() {
if (!this.selectedItemSublist) this.sublist = null
this.showMenu = false
@@ -48,6 +48,10 @@ export default {
text: 'Added At',
value: 'addedAt'
},
{
text: 'Volume #',
value: 'book.volumeNumber'
},
{
text: 'Duration',
value: 'duration'
+110 -29
View File
@@ -1,5 +1,5 @@
<template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
<modals-modal v-model="show" :width="800" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
@@ -8,20 +8,55 @@
<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 class="flex py-2 -mx-2">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="newUser.username" label="Username" class="mx-2" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-if="!isEditingRoot" v-model="newUser.password" :label="isNew ? 'Password' : 'Change Password'" type="password" class="mx-2" />
</div>
</div>
<div class="flex py-2">
<div class="px-2">
<ui-input-dropdown v-model="newUser.type" label="Account Type" :editable="false" :items="accountTypes" />
<ui-input-dropdown v-model="newUser.type" label="Account Type" :disabled="isEditingRoot" :editable="false" :items="accountTypes" @input="userTypeUpdated" />
</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 v-show="!isEditingRoot" class="flex items-center pt-4 px-2">
<p class="px-3 font-semibold" :class="isEditingRoot ? 'text-gray-300' : ''">Is Active</p>
<ui-toggle-switch v-model="newUser.isActive" :disabled="isEditingRoot" />
</div>
</div>
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 mt-4">
<p class="text-lg mb-2">Permissions</p>
<div class="flex items-center my-2 max-w-lg">
<div class="w-1/2">
<p>Can Download</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.download" />
</div>
</div>
<div class="flex items-center my-2 max-w-lg">
<div class="w-1/2">
<p>Can Update</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.update" />
</div>
</div>
<div class="flex items-center my-2 max-w-lg">
<div class="w-1/2">
<p>Can Delete</p>
</div>
<div class="w-1/2">
<ui-toggle-switch v-model="newUser.permissions.delete" />
</div>
</div>
</div>
<div class="flex pt-4">
<div class="flex-grow" />
<ui-btn color="success" type="submit">Submit</ui-btn>
@@ -68,7 +103,10 @@ export default {
}
},
title() {
return this.isNew ? 'Add New Account' : `Update "${(this.account || {}).username}" Account`
return this.isNew ? 'Add New Account' : `Update Account: ${(this.account || {}).username}`
},
isEditingRoot() {
return this.account && this.account.type === 'root'
}
},
methods: {
@@ -77,6 +115,39 @@ export default {
this.$toast.error('Enter a username')
return
}
if (this.isNew) {
this.submitCreateAccount()
} else {
this.submitUpdateAccount()
}
},
submitUpdateAccount() {
var account = { ...this.newUser }
if (!account.password || account.type === 'root') {
delete account.password
}
if (account.type === 'root' && !account.isActive) return
this.processing = true
this.$axios
.$patch(`/api/user/${this.account.id}`, account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to update account: ${data.error}`)
} else {
this.$toast.success('Account updated')
this.show = false
}
})
.catch((error) => {
console.error('Failed to update account', error)
this.processing = false
this.$toast.error('Failed to update account')
})
},
submitCreateAccount() {
if (!this.newUser.password) {
this.$toast.error('Must have a password, only root user can have an empty password')
return
@@ -84,29 +155,33 @@ export default {
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.$axios
.$post('/api/user', account)
.then((data) => {
this.processing = false
if (data.error) {
this.$toast.error(`Failed to create account: ${data.error}`)
} else {
this.$toast.success('New account created')
})
}
this.show = false
}
})
.catch((error) => {
console.error('Failed to create account', error)
this.processing = false
this.$toast.error('Failed to create account')
})
},
toggleActive() {
this.newUser.isActive = !this.newUser.isActive
},
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
update: type === 'admin',
delete: type === 'admin'
}
},
init() {
this.isNew = !this.account
if (this.account) {
@@ -114,14 +189,20 @@ export default {
username: this.account.username,
password: this.account.password,
type: this.account.type,
isActive: this.account.isActive
isActive: this.account.isActive,
permissions: { ...this.account.permissions }
}
} else {
this.newUser = {
username: null,
password: null,
type: 'user',
isActive: true
isActive: true,
permissions: {
download: true,
update: false,
delete: false
}
}
}
}
@@ -0,0 +1,44 @@
<template>
<modals-modal v-model="show" :width="500" :height="'unset'">
<div class="w-full rounded-lg bg-primary box-shadow-md overflow-y-auto overflow-x-hidden" style="max-height: 500px">
<template v-for="chap in chapters">
<div :key="chap.id" class="flex items-center px-6 py-3 justify-start cursor-pointer hover:bg-bg bg-opacity-20 rounded-lg relative" @click="clickChapter(chap)">
{{ chap.title }}
<span class="flex-grow" />
<span class="font-mono text-sm text-gray-300">{{ $secondsToTimestamp(chap.start) }}</span>
</div>
</template>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
chapters: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
}
},
methods: {
clickChapter(chap) {
this.$emit('select', chap)
}
},
mounted() {}
}
</script>
+47 -4
View File
@@ -1,16 +1,16 @@
<template>
<modals-modal v-model="show" :width="800" :height="500" :processing="processing">
<modals-modal v-model="show" :width="800" :height="height" :processing="processing" :content-margin-top="75">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 w-full flex">
<template v-for="tab in tabs">
<template v-for="tab in availableTabs">
<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">
<div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300">
<keep-alive>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
</keep-alive>
@@ -22,7 +22,6 @@
export default {
data() {
return {
selectedTab: 'details',
processing: false,
audiobook: null,
fetchOnShow: false,
@@ -47,6 +46,11 @@ export default {
title: 'Tracks',
component: 'modals-edit-tabs-tracks'
},
{
id: 'chapters',
title: 'Chapters',
component: 'modals-edit-tabs-chapters'
},
{
id: 'download',
title: 'Download',
@@ -59,6 +63,15 @@ export default {
show: {
handler(newVal) {
if (newVal) {
var availableTabIds = this.availableTabs.map((tab) => tab.id)
if (!availableTabIds.length) {
this.show = false
return
}
if (!availableTabIds.includes(this.selectedTab)) {
this.selectedTab = availableTabIds[0]
}
if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) {
if (this.fetchOnShow) this.fetchFull()
return
@@ -79,10 +92,40 @@ export default {
this.$store.commit('setShowEditModal', val)
}
},
selectedTab: {
get() {
return this.$store.state.editModalTab
},
set(val) {
this.$store.commit('setEditModalTab', val)
}
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
availableTabs() {
if (!this.userCanUpdate && !this.userCanDownload) return []
return this.tabs.filter((tab) => {
if (tab.id === 'download' && this.isMissing) return false
if ((tab.id === 'download' || tab.id === 'tracks') && this.userCanDownload) return true
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
return false
})
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)
},
tabName() {
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
return _tab ? _tab.component : ''
},
isMissing() {
return this.selectedAudiobook.isMissing
},
selectedAudiobook() {
return this.$store.state.selectedAudiobook || {}
},
+6 -2
View File
@@ -1,12 +1,12 @@
<template>
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-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-40 opacity-0">
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
<div 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">
<span class="material-icons text-4xl">close</span>
</div>
<slot name="outer" />
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth }" v-click-outside="clickBg">
<div ref="content" style="min-width: 400px; min-height: 200px" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" v-click-outside="clickBg">
<slot />
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
<ui-loading-indicator />
@@ -31,6 +31,10 @@ export default {
height: {
type: [String, Number],
default: 'unset'
},
contentMarginTop: {
type: Number,
default: 50
}
},
data() {
@@ -0,0 +1,59 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div v-if="!chapters.length" class="flex my-4 text-center justify-center text-xl">No Chapters</div>
<table v-else class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left w-16"><span class="px-4">Id</span></th>
<th class="text-left">Title</th>
<th class="text-center">Start</th>
<th class="text-center">End</th>
</tr>
<template v-for="chapter in chapters">
<tr :key="chapter.id">
<td class="text-left">
<p class="px-4">{{ chapter.id }}</p>
</td>
<td class="font-book">
{{ chapter.title }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.start) }}
</td>
<td class="font-mono text-center">
{{ $secondsToTimestamp(chapter.end) }}
</td>
</tr>
</template>
</table>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
}
},
data() {
return {
chapters: []
}
},
watch: {
audiobook: {
immediate: true,
handler(newVal) {
if (newVal) this.init()
}
}
},
computed: {},
methods: {
init() {
this.chapters = this.audiobook.chapters || []
}
}
}
</script>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<div class="flex">
<div class="relative">
<cards-book-cover :audiobook="audiobook" />
+98 -49
View File
@@ -1,50 +1,63 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
</div>
<form @submit.prevent="submitForm">
<ui-text-input-with-label v-model="details.title" label="Title" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
<div class="w-full h-full relative">
<form class="w-full h-full" @submit.prevent="submitForm">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50">
<div class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" />
</div> -->
<ui-text-input-with-label v-model="details.title" label="Title" />
<ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" />
<div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-text-input-with-label v-model="details.author" label="Author" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.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="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" />
<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 mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="details.narrarator" label="Narrarator" />
</div>
</div>
</div>
<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 class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'">
<div class="flex px-4">
<ui-btn v-if="userCanDelete" color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</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" />
<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>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</form>
</div>
@@ -63,15 +76,18 @@ export default {
return {
details: {
title: null,
subtitle: null,
description: null,
author: null,
narrarator: null,
series: null,
volumeNumber: null,
publishYear: null,
genres: []
},
newTags: [],
resettingProgress: false
resettingProgress: false,
isScrollable: false
}
},
watch: {
@@ -97,11 +113,8 @@ export default {
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
genres() {
return this.$store.state.audiobooks.genres
@@ -136,8 +149,10 @@ export default {
},
init() {
this.details.title = this.book.title
this.details.subtitle = this.book.subtitle
this.details.description = this.book.description
this.details.author = this.book.author
this.details.narrarator = this.book.narrarator
this.details.genres = this.book.genres || []
this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
@@ -162,7 +177,7 @@ export default {
}
},
deleteAudiobook() {
if (confirm(`Are you sure you want to remove this audiobook?`)) {
if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) {
this.isProcessing = true
this.$axios
.$delete(`/api/audiobook/${this.audiobookId}`)
@@ -177,7 +192,41 @@ export default {
this.isProcessing = false
})
}
},
checkIsScrollable() {
this.$nextTick(() => {
if (this.$refs.formWrapper) {
if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) {
this.isScrollable = true
} else {
this.isScrollable = false
}
}
})
},
setResizeObserver() {
try {
this.$nextTick(() => {
const resizeObserver = new ResizeObserver(() => {
this.checkIsScrollable()
})
resizeObserver.observe(this.$refs.formWrapper)
})
} catch (error) {
console.error('Failed to set resize observer')
}
}
},
mounted() {
// this.init()
this.setResizeObserver()
}
}
</script>
</script>
<style scoped>
.details-form-wrapper {
height: calc(100% - 70px);
max-height: calc(100% - 70px);
}
</style>
+86 -29
View File
@@ -1,24 +1,54 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6">
<p class="text-center text-lg mb-4 py-8">Preparing downloads can take 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 60 minutes, then be deleted.<br />Download will timeout after 15 minutes.</p>
<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>
<!-- <p class="text-lg">{{ isSingleTrack ? 'Single Track' : 'M4B Audiobook File' }}</p> -->
<p class="text-lg">M4B Audiobook File <span class="text-error">*</span></p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .M4B audiobook file with embedded cover image and chapters.</p>
</div>
<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>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="singleDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
<!-- <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> -->
<ui-btn v-if="singleDownloadStatus !== $constants.DownloadStatus.READY" :loading="singleDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(singleAudioDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(singleAudioDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full border border-black-200 p-4 my-4">
<div class="flex items-center">
<div>
<p v-if="totalFiles > 1" class="text-lg">Zip {{ totalFiles }} Files</p>
<p v-else>Zip 1 File</p>
<p class="max-w-xs text-sm pt-2 text-gray-300">Generate a .ZIP file from the contents of the audiobook directory.</p>
</div>
<div class="flex-grow" />
<div>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.FAILED" class="text-error mb-2">Download Failed</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.READY" class="text-success mb-2">Download Ready!</p>
<p v-if="zipDownloadStatus === $constants.DownloadStatus.EXPIRED" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="zipDownloadStatus !== $constants.DownloadStatus.READY" :loading="zipDownloadStatus === $constants.DownloadStatus.PENDING" :disabled="tempDisable" @click="startZipDownload">Start Download</ui-btn>
<div v-else>
<ui-btn @click="downloadWithProgress(zipDownload)">Download</ui-btn>
<p class="px-0.5 py-1 text-sm font-mono text-center">Size: {{ $bytesPretty(zipDownload.size) }}</p>
</div>
</div>
</div>
</div>
<div class="w-full flex items-center justify-center absolute bottom-4 left-0 right-0 text-center">
<p class="text-error text-lg">* <strong>Experimental:</strong> Merging multiple .m4b files may have issues. <a href="https://github.com/advplyr/audiobookshelf/issues" class="underline text-blue-600" target="_blank">Report issues here.</a></p>
</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">
@@ -51,7 +81,7 @@ export default {
}
},
watch: {
singleAudioDownloadPending(newVal) {
singleDownloadStatus(newVal) {
if (newVal) {
this.tempDisable = false
}
@@ -67,25 +97,36 @@ export default {
singleAudioDownload() {
return this.downloads.find((d) => d.type === 'singleAudio')
},
singleAudioDownloadPending() {
return this.singleAudioDownload && this.singleAudioDownload.isPending
singleDownloadStatus() {
return this.singleAudioDownload ? this.singleAudioDownload.status : false
},
singleAudioDownloadFailed() {
return this.singleAudioDownload && this.singleAudioDownload.isFailed
zipDownload() {
return this.downloads.find((d) => d.type === 'zip')
},
singleAudioDownloadReady() {
return this.singleAudioDownload && this.singleAudioDownload.isReady
zipDownloadStatus() {
return this.zipDownload ? this.zipDownload.status : false
},
singleAudioDownloadExpired() {
return this.singleAudioDownload && this.singleAudioDownload.isExpired
isSingleTrack() {
if (!this.audiobook.tracks) return false
return this.audiobook.tracks.length === 1
},
zipBundleDownload() {
return this.downloads.find((d) => d.type === 'zipBundle')
singleTrackPath() {
if (!this.isSingleTrack) return null
return this.audiobook.tracks[0].path
},
audioFiles() {
return this.audiobook ? this.audiobook.audioFiles || [] : []
},
otherFiles() {
return this.audiobook ? this.audiobook.otherFiles || [] : []
},
totalFiles() {
return this.audioFiles.length + this.otherFiles.length
}
},
methods: {
startSingleAudioDownload() {
console.log('Download request received', this.audiobook)
startZipDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
@@ -94,14 +135,30 @@ export default {
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio'
type: 'zip'
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress() {
var downloadId = this.singleAudioDownload.id
startSingleAudioDownload() {
// console.log('Download request received', this.audiobook)
this.tempDisable = true
setTimeout(() => {
this.tempDisable = false
}, 1000)
var downloadPayload = {
audiobookId: this.audiobook.id,
type: 'singleAudio',
includeMetadata: true,
includeCover: true
}
this.$root.socket.emit('download', downloadPayload)
},
downloadWithProgress(download) {
var downloadId = download.id
var downloadUrl = `${process.env.serverUrl}/api/download/${downloadId}`
var filename = this.singleAudioDownload.filename
var filename = download.filename
this.isDownloading = true
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="w-full h-full overflow-hidden">
<div class="w-full h-full overflow-hidden px-4 py-6">
<form @submit.prevent="submitSearch">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">
+20 -4
View File
@@ -1,7 +1,7 @@
<template>
<div class="w-full h-full overflow-y-auto overflow-x-hidden">
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="flex mb-4">
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn>
</nuxt-link>
</div>
@@ -11,6 +11,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="showDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -26,6 +27,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="showDownload" class="font-mono text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
@@ -54,12 +58,24 @@ export default {
}
}
},
computed: {},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
isMissing() {
return this.audiobook.isMissing
},
showDownload() {
return this.userCanDownload && !this.isMissing
}
},
methods: {
init() {
this.audioFiles = this.audiobook.audioFiles
this.tracks = this.audiobook.tracks
console.log('INIT', this.audiobook)
}
}
}
+6 -2
View File
@@ -4,7 +4,7 @@
<p class="pr-4">Other Audio Files</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ files.length }}</span>
<div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -56,7 +56,11 @@ export default {
showTracks: false
}
},
computed: {},
computed: {
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
+13 -2
View File
@@ -4,7 +4,7 @@
<p class="pr-4">Audio Tracks</p>
<span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span>
<div class="flex-grow" />
<nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-4">
<ui-btn small color="primary">Manage Tracks</ui-btn>
</nuxt-link>
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''">
@@ -19,6 +19,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th v-if="userCanDownload" class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@@ -34,6 +35,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td v-if="userCanDownload" class="text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>
@@ -56,7 +60,14 @@ export default {
showTracks: false
}
},
computed: {},
computed: {
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
}
},
methods: {
clickBar() {
this.showTracks = !this.showTracks
+2 -2
View File
@@ -59,7 +59,7 @@ export default {
</script>
<style>
button.btn::before {
.btn::before {
content: '';
position: absolute;
border-radius: 6px;
@@ -70,7 +70,7 @@ button.btn::before {
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.btn:hover:not(:disabled)::before {
.btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button:disabled::before {
+62
View File
@@ -0,0 +1,62 @@
<template>
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :disabled="disabled" :class="className" @click="clickBtn">
<span class="material-icons icon-text">{{ icon }}</span>
</button>
</template>
<script>
export default {
props: {
icon: String,
disabled: Boolean,
bgColor: {
type: String,
default: 'primary'
}
},
data() {
return {}
},
computed: {
className() {
var classes = []
classes.push(`bg-${this.bgColor}`)
return classes.join(' ')
}
},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn:disabled {
cursor: not-allowed;
}
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+9 -8
View File
@@ -1,10 +1,10 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<div class="w-full" :class="disabled ? 'cursor-not-allowed' : ''">
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ 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 ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-2" :class="disabled ? 'bg-bg pointer-events-none text-gray-400' : 'bg-primary'">
<input ref="input" v-model="textInput" :disabled="disabled" :readonly="!editable" class="h-full w-full bg-transparent focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div>
</form>
@@ -33,6 +33,7 @@
export default {
props: {
value: [String, Number],
disabled: Boolean,
label: String,
items: {
type: Array,
@@ -95,7 +96,7 @@ export default {
}
this.isFocused = false
if (this.input !== this.textInput) {
var val = this.$cleanString(this.textInput) || null
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
@@ -104,7 +105,7 @@ export default {
}, 50)
},
submitForm() {
var val = this.$cleanString(this.textInput) || null
var val = this.textInput ? this.textInput.trim() : null
this.input = val
if (val && !this.items.includes(val)) {
this.$emit('newItem', val)
@@ -115,10 +116,10 @@ export default {
var newValue = this.input === item ? null : item
this.textInput = null
this.currentSearch = null
this.input = this.$cleanString(newValue) || null
this.input = this.textInput ? this.textInput.trim() : null
if (this.$refs.input) this.$refs.input.blur()
}
},
mounted() {}
}
</script>
</script>
+2 -2
View File
@@ -1,13 +1,13 @@
<template>
<div class="w-40">
<div class="bg-white border py-2 px-5 rounded-lg flex items-center flex-col">
<div class="bg-bg border border-gray-500 py-2 px-5 rounded-lg flex items-center flex-col box-shadow-md">
<div class="loader-dots block relative w-20 h-5 mt-2">
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
<div class="absolute top-0 mt-1 w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div class="text-gray-500 text-xs font-light mt-2 text-center">{{ text }}</div>
<div class="text-gray-200 text-xs font-light mt-2 text-center">{{ text }}</div>
</div>
</div>
</template>
+5 -8
View File
@@ -8,7 +8,7 @@
<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) }}
{{ 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>
@@ -18,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">{{ 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>
@@ -75,8 +75,7 @@ export default {
}
return this.items.filter((i) => {
var normie = this.$snakeToNormal(i)
var iValue = String(normie).toLowerCase()
var iValue = String(i).toLowerCase()
return iValue.includes(this.currentSearch.toLowerCase())
})
}
@@ -170,8 +169,7 @@ export default {
})
},
insertNewItem(item) {
var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem)
this.selected.push(item)
this.$emit('input', this.selected)
this.textInput = null
this.currentSearch = null
@@ -183,9 +181,8 @@ export default {
if (!this.textInput) return
var cleaned = this.textInput.toLowerCase().trim()
var cleanedKebab = this.$normalToSnake(cleaned)
var matchesItem = this.items.find((i) => {
return i === cleaned || cleanedKebab === i
return i === cleaned
})
if (matchesItem) {
this.clickedOption(null, matchesItem)
+56
View File
@@ -0,0 +1,56 @@
<template>
<button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn">
<div class="w-5 h-5 text-white relative">
<svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" />
</svg>
</div>
</button>
</template>
<script>
export default {
props: {
isRead: Boolean,
disabled: Boolean
},
data() {
return {}
},
computed: {},
methods: {
clickBtn(e) {
if (this.disabled) {
e.preventDefault()
return
}
this.$emit('click')
e.stopPropagation()
}
},
mounted() {}
}
</script>
<style>
button.icon-btn::before {
content: '';
position: absolute;
border-radius: 6px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0);
transition: all 0.1s ease-in-out;
}
button.icon-btn:hover:not(:disabled)::before {
background-color: rgba(255, 255, 255, 0.1);
}
button.icon-btn:disabled::before {
background-color: rgba(0, 0, 0, 0.2);
}
</style>
+16 -3
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'" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
<input v-model="inputValue" :type="type" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none border border-gray-600" :class="classList" @keyup="keyup" @change="change" @focus="focused" @blur="blurred" />
</template>
<script>
@@ -12,8 +12,15 @@ export default {
type: String,
default: 'text'
},
transparent: Boolean,
disabled: Boolean
disabled: Boolean,
paddingY: {
type: Number,
default: 2
},
paddingX: {
type: Number,
default: 3
}
},
data() {
return {}
@@ -26,6 +33,12 @@ export default {
set(val) {
this.$emit('input', val)
}
},
classList() {
var _list = []
_list.push(`px-${this.paddingX}`)
_list.push(`py-${this.paddingY}`)
return _list.join(' ')
}
},
methods: {
+4 -1
View File
@@ -1,6 +1,8 @@
<template>
<div class="w-full">
<p class="px-1 text-sm font-semibold">{{ label }}</p>
<p class="px-1 text-sm font-semibold">
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
<ui-text-input v-model="inputValue" :disabled="disabled" :type="type" class="w-full" />
</div>
</template>
@@ -10,6 +12,7 @@ export default {
props: {
value: [String, Number],
label: String,
note: String,
type: {
type: String,
default: 'text'
+20 -6
View File
@@ -1,7 +1,7 @@
<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 class="border rounded-full border-black-100 flex items-center cursor-pointer w-12 justify-start" :class="className" @click="clickToggle">
<span class="rounded-full border w-6 h-6 border-black-50 shadow transform transition-transform duration-100" :class="switchClassName"></span>
</div>
</div>
</template>
@@ -9,7 +9,16 @@
<script>
export default {
props: {
value: Boolean
value: Boolean,
onColor: {
type: String,
default: 'success'
},
offColor: {
type: String,
default: 'primary'
},
disabled: Boolean
},
computed: {
toggleValue: {
@@ -20,13 +29,18 @@ export default {
this.$emit('input', val)
}
},
toggleColor() {
return this.toggleValue ? 'bg-success' : 'bg-primary'
className() {
if (this.disabled) return 'bg-bg cursor-not-allowed'
return this.toggleValue ? `bg-${this.onColor}` : `bg-${this.offColor}`
},
switchClassName() {
var bgColor = this.disabled ? 'bg-gray-300' : 'bg-white'
return this.toggleValue ? 'translate-x-6 ' + bgColor : bgColor
}
},
methods: {
clickToggle() {
console.log('click toggle', this.toggleValue)
if (this.disabled) return
this.toggleValue = !this.toggleValue
}
}
+53 -8
View File
@@ -14,7 +14,8 @@ export default {
direction: {
type: String,
default: 'right'
}
},
disabled: Boolean
},
data() {
return {
@@ -22,27 +23,71 @@ export default {
isShowing: false
}
},
watch: {
text() {
this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
}
},
methods: {
updateText() {
if (this.tooltip) {
this.tooltip.innerHTML = this.text
this.setTooltipPosition(this.tooltip)
}
},
getTextWidth() {
var styles = {
'font-size': '0.75rem'
}
var size = this.$calculateTextSize(this.text, styles)
console.log('Text Size', size.width, size.height)
return size.width
},
createTooltip() {
if (!this.$refs.box) return
var tooltip = document.createElement('div')
tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg'
tooltip.style.zIndex = 100
tooltip.innerHTML = this.text
this.setTooltipPosition(tooltip)
this.tooltip = tooltip
},
setTooltipPosition(tooltip) {
var boxChow = this.$refs.box.getBoundingClientRect()
var shouldMount = !tooltip.isConnected
// Calculate size of tooltip
if (shouldMount) document.body.appendChild(tooltip)
var { width, height } = tooltip.getBoundingClientRect()
if (shouldMount) tooltip.remove()
var top = 0
var left = 0
if (this.direction === 'right') {
top = boxChow.top
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left + boxChow.width + 4
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left
left = boxChow.left - width / 2 + boxChow.width / 2
} else if (this.direction === 'top') {
top = boxChow.top - height - 4
left = boxChow.left - width / 2 + boxChow.width / 2
} else if (this.direction === 'left') {
top = boxChow.top - height / 2 + boxChow.height / 2
left = boxChow.left - width - 4
}
var tooltip = document.createElement('div')
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
tooltip.innerText = this.text
this.tooltip = tooltip
},
showTooltip() {
if (this.disabled) return
if (!this.tooltip) {
this.createTooltip()
}
+81 -19
View File
@@ -56,6 +56,9 @@ export default {
this.$store.commit('user/setUser', payload.user)
this.$store.commit('user/setSettings', payload.user.settings)
}
if (payload.serverSettings) {
this.$store.commit('setServerSettings', payload.serverSettings)
}
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
@@ -99,6 +102,7 @@ export default {
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
@@ -124,39 +128,61 @@ export default {
this.$store.commit('user/setSettings', user.settings)
}
},
downloadToastClick(download) {
if (!download || !download.audiobookId) {
return console.error('Invalid download object', download)
}
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
if (!audiobook) {
return console.error('Audiobook not found for download', download)
}
this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
},
downloadStarted(download) {
var filename = download.filename
this.$toast.success(`Preparing download for "${filename}"`)
download.isPending = true
download.status = this.$constants.DownloadStatus.PENDING
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadReady(download) {
var filename = download.filename
this.$toast.success(`Download "${filename}" is ready!`)
download.status = this.$constants.DownloadStatus.READY
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isPending = false
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
} else {
this.$toast.success(`Download "${download.filename}" is ready!`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadFailed(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" is failed`)
download.status = this.$constants.DownloadStatus.FAILED
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
download.isFailed = true
download.isReady = false
download.isPending = false
var failedMsg = download.isTimedOut ? 'timed out' : 'failed'
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
} else {
console.warn('Download failed no existing download', existingDownload)
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
}
this.$store.commit('downloads/addUpdateDownload', download)
},
downloadKilled(download) {
var filename = download.filename
this.$toast.error(`Download "${filename}" was terminated`)
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
if (existingDownload && existingDownload.toastId !== undefined) {
download.toastId = existingDownload.toastId
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
} else {
console.warn('Download killed no existing download found', existingDownload)
this.$toast.error(`Download "${download.filename}" was terminated`)
}
this.$store.commit('downloads/removeDownload', download)
},
downloadExpired(download) {
download.isExpired = true
download.isReady = false
download.isPending = false
download.status = this.$constants.DownloadStatus.EXPIRED
this.$store.commit('downloads/addUpdateDownload', download)
},
initializeSocket() {
@@ -206,10 +232,40 @@ export default {
this.socket.on('download_failed', this.downloadFailed)
this.socket.on('download_killed', this.downloadKilled)
this.socket.on('download_expired', this.downloadExpired)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
var latestVersion = versionData.latestVersion
if (!ignoreVersion || ignoreVersion !== latestVersion) {
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
position: 'top-center',
toastClassName: 'cursor-pointer',
bodyClassName: 'custom-class-1',
timeout: 20000,
closeOnClick: false,
draggable: false,
hideProgressBar: false,
onClick: () => {
window.open(versionData.githubTagUrl, '_blank')
},
onClose: () => {
localStorage.setItem('ignoreVersion', versionData.latestVersion)
}
})
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
}
},
mounted() {
this.initializeSocket()
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
@@ -217,4 +273,10 @@ export default {
}
}
}
</script>
</script>
<style>
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
</style>
+1
View File
@@ -47,6 +47,7 @@ module.exports = {
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
'@/plugins/constants.js',
'@/plugins/init.client.js',
'@/plugins/axios.js',
'@/plugins/toast.js'
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.0.0",
"version": "1.1.12",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {
+14 -1
View File
@@ -1,6 +1,6 @@
<template>
<div class="w-full h-full p-8">
<div class="w-full max-w-2xl mx-auto">
<div class="w-full max-w-xl mx-auto">
<h1 class="text-2xl">Account</h1>
<div class="my-4">
@@ -27,6 +27,10 @@
</div>
</form>
</div>
<div class="py-4 mt-8 flex">
<ui-btn color="primary flex items-center text-lg" @click="logout"><span class="material-icons mr-4 icon-text">logout</span>Logout</ui-btn>
</div>
</div>
</div>
</template>
@@ -56,6 +60,15 @@ export default {
}
},
methods: {
logout() {
this.$axios.$post('/logout').catch((error) => {
console.error(error)
})
if (localStorage.getItem('token')) {
localStorage.removeItem('token')
}
this.$router.push('/login')
},
resetForm() {
this.password = null
this.newPassword = null
+56 -14
View File
@@ -19,16 +19,15 @@
<div class="font-mono w-20 text-center">Duration</div>
<div class="font-mono text-center w-20">Status</div>
<div class="font-mono w-56">Notes</div>
<div class="font-book w-40">Include in Tracklist</div>
</div>
<draggable v-model="files" v-bind="dragOptions" class="list-group border border-gray-600" draggable=".item" tag="ul" @start="drag = true" @end="drag = false">
<transition-group type="transition" :name="!drag ? 'flip-list' : null">
<li v-for="(audio, index) in files" :key="audio.path" class="w-full list-group-item item flex items-center">
<li v-for="(audio, index) in files" :key="audio.path" :class="audio.include ? 'item' : 'exclude'" class="w-full list-group-item flex items-center">
<div class="font-book text-center px-4 py-1 w-12">
{{ index + 1 }}
</div>
<div class="font-book text-center px-4 w-12">
{{ audio.index }}
{{ audio.include ? index - numExcluded + 1 : -1 }}
</div>
<div class="font-book text-center px-4 w-12">{{ audio.index }}</div>
<div class="font-book text-center px-2 w-32">
{{ audio.trackNumFromFilename }}
</div>
@@ -51,6 +50,9 @@
<div class="font-sans text-xs font-normal w-56">
{{ audio.error }}
</div>
<div class="font-sans text-xs font-normal w-40 flex justify-center">
<ui-toggle-switch v-model="audio.include" :off-color="'error'" @input="includeToggled(audio)" />
</div>
</li>
</transition-group>
</draggable>
@@ -69,6 +71,9 @@ export default {
if (!store.state.user.user) {
return redirect(`/login?redirect=${route.path}`)
}
if (!store.getters['user/getUserCanUpdate']) {
return redirect('/?error=unauthorized')
}
var audiobook = await app.$axios.$get(`/api/audiobook/${params.id}`).catch((error) => {
console.error('Failed', error)
return false
@@ -77,10 +82,9 @@ export default {
console.error('No audiobook...', params.id)
return redirect('/')
}
let index = 0
return {
audiobook,
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, index: ++index })) : []
files: audiobook.audioFiles ? audiobook.audioFiles.map((af) => ({ ...af, include: !af.exclude })) : []
}
},
data() {
@@ -98,6 +102,13 @@ export default {
audioFiles() {
return this.audiobook.audioFiles || []
},
numExcluded() {
var count = 0
this.files.forEach((file) => {
if (!file.include) count++
})
return count
},
missingPartChunks() {
if (this.missingParts === 1) return this.missingParts[0]
var chunks = []
@@ -164,15 +175,36 @@ export default {
}
},
methods: {
includeToggled(audio) {
var new_index = 0
if (audio.include) {
new_index = this.numExcluded
}
var old_index = this.files.findIndex((f) => f.ino === audio.ino)
if (new_index >= this.files.length) {
var k = new_index - this.files.length + 1
while (k--) {
this.files.push(undefined)
}
}
this.files.splice(new_index, 0, this.files.splice(old_index, 1)[0])
},
saveTracklist() {
console.log('Tracklist', this.files)
var orderedFileData = this.files.map((file) => {
return {
index: file.index,
filename: file.filename,
ino: file.ino,
exclude: !file.include
}
})
this.saving = true
this.$axios
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { files: this.files })
.$patch(`/api/audiobook/${this.audiobook.id}/tracks`, { orderedFileData })
.then((data) => {
console.log('Finished patching files', data)
this.saving = false
// this.$router.go()
this.$toast.success('Tracks Updated')
this.$router.push(`/audiobook/${this.audiobookId}`)
})
@@ -207,16 +239,26 @@ export default {
.list-group {
min-height: 30px;
}
.list-group-item {
.list-group-item:not(.exclude) {
cursor: n-resize;
}
.list-group-item:not(.ghost):hover {
.list-group-item.exclude {
cursor: not-allowed;
}
.list-group-item:not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item:nth-child(even):not(.ghost) {
.list-group-item:nth-child(even):not(.ghost):not(.exclude) {
background-color: rgba(0, 0, 0, 0.25);
}
.list-group-item:nth-child(even):not(.ghost):hover {
.list-group-item:nth-child(even):not(.ghost):not(.exclude):hover {
background-color: rgba(0, 0, 0, 0.1);
}
.list-group-item.exclude:not(.ghost) {
background-color: rgba(255, 0, 0, 0.25);
}
.list-group-item.exclude:not(.ghost):hover {
background-color: rgba(223, 0, 0, 0.25);
}
</style>
+81 -16
View File
@@ -5,7 +5,7 @@
<div class="w-52" style="min-width: 208px">
<div class="relative">
<cards-book-cover :audiobook="audiobook" :width="208" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div>
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
</div>
</div>
<div class="flex-grow px-10">
@@ -22,24 +22,44 @@
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</p>
<div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''">
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
<p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div class="flex items-center pt-4">
<ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream">
<ui-btn v-if="!isMissing" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @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-else color="error" :padding-x="4" small class="flex items-center h-9 mr-2">
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span>
Missing
</ui-btn>
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip>
<ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
<ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
</ui-tooltip>
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" />
</ui-tooltip>
<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>
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
<span class="material-icons text-sm">close</span>
</div>
</div>
<div class="flex-grow" />
</div>
<div class="my-4">
<p class="text-sm text-gray-100">{{ description }}</p>
</div>
<p class="text-sm my-4 text-gray-100">{{ description }}</p>
<div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4">
<p class="text-sm mb-2">
@@ -53,7 +73,7 @@
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
</p>
<div>
<p v-for="part in invalidParts" :key="part" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
<p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p>
</div>
</div>
@@ -88,7 +108,17 @@ export default {
},
data() {
return {
resettingProgress: false
isRead: false,
resettingProgress: false,
isProcessingReadUpdate: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
@@ -126,6 +156,9 @@ export default {
})
return chunks
},
isMissing() {
return this.audiobook.isMissing
},
missingParts() {
return this.audiobook.missingParts || []
},
@@ -149,7 +182,7 @@ export default {
},
authorTooltipText() {
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
return txt.join('\n')
return txt.join('<br>')
},
series() {
return this.book.series || null
@@ -189,7 +222,7 @@ export default {
return this.audiobook.audioFiles || []
},
description() {
return this.book.description || 'No Description'
return this.book.description || ''
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@@ -200,6 +233,9 @@ export default {
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
@@ -209,11 +245,37 @@ export default {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
isStreaming() {
streaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
}
},
methods: {
toggleRead() {
var updatePayload = {
isRead: !this.isRead
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`)
})
},
openRssFeed() {
this.$axios
.$post('/api/feed', { audiobookId: this.audiobook.id })
@@ -269,6 +331,9 @@ export default {
this.resettingProgress = false
})
}
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
}
},
mounted() {
+8
View File
@@ -9,6 +9,8 @@
<div class="flex-grow pl-4">
<ui-text-input-with-label v-model="audiobook.book.title" label="Title" />
<ui-text-input-with-label v-model="audiobook.book.subtitle" label="Subtitle" class="mt-2" />
<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" />
@@ -37,6 +39,12 @@
<ui-multi-select v-model="audiobook.tags" label="Tags" :items="tags" />
</div>
</div>
<div class="flex mt-2 -mx-1">
<div class="w-1/2 px-1">
<ui-text-input-with-label v-model="audiobook.book.narrarator" label="Narrarator" />
</div>
</div>
</div>
</div>
</template>
+52 -9
View File
@@ -17,7 +17,7 @@
<th style="width: 200px">Created At</th>
<th style="width: 100px"></th>
</tr>
<tr v-for="user in users" :key="user.id">
<tr v-for="user in users" :key="user.id" :class="user.isActive ? '' : 'bg-error bg-opacity-20'">
<td>
{{ user.username }} <span class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
</td>
@@ -27,6 +27,7 @@
</td>
<td>
<div class="w-full flex justify-center">
<span class="material-icons hover:text-gray-400 cursor-pointer text-base pr-2" @click="editUser(user)">edit</span>
<span v-show="user.type !== 'root'" class="material-icons text-base hover:text-error cursor-pointer" @click="deleteUserClick(user)">delete</span>
</div>
</td>
@@ -35,8 +36,16 @@
</div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="py-4 mb-8">
<p class="text-2xl">Scanner</p>
<div class="flex items-start py-2">
<p class="text-2xl">Scanner</p>
<div class="py-2">
<div class="flex items-center">
<ui-toggle-switch v-model="newServerSettings.scannerParseSubtitle" @input="updateScannerParseSubtitle" />
<ui-tooltip :text="parseSubtitleTooltip">
<p class="pl-4 text-lg">Parse Subtitles <span class="material-icons icon-text">info_outlined</span></p>
</ui-tooltip>
</div>
</div>
<div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
@@ -68,7 +77,7 @@
</div>
<div class="fixed bottom-0 left-0 w-10 h-10" @dblclick="setDeveloperMode"></div>
<modals-account-modal v-model="showAccountModal" />
<modals-account-modal v-model="showAccountModal" :account="selectedAccount" />
</div>
</template>
@@ -83,11 +92,26 @@ export default {
return {
isResettingAudiobooks: false,
users: [],
selectedAccount: null,
showAccountModal: false,
isDeletingUser: false
isDeletingUser: false,
newServerSettings: {}
}
},
watch: {
serverSettings(newVal, oldVal) {
if (newVal && !oldVal) {
this.newServerSettings = { ...this.serverSettings }
}
}
},
computed: {
parseSubtitleTooltip() {
return 'Extract subtitles from audiobook directory names.<br>Subtitle must be seperated by " - "<br>i.e. "Book Title - A Subtitle Here" has the subtitle "A Subtitle Here"'
},
serverSettings() {
return this.$store.state.serverSettings
},
streamAudiobook() {
return this.$store.state.streamAudiobook
},
@@ -99,6 +123,19 @@ export default {
}
},
methods: {
updateScannerParseSubtitle(val) {
var payload = {
scannerParseSubtitle: val
}
this.$store
.dispatch('updateServerSettings', payload)
.then((success) => {
console.log('Updated Server Settings', success)
})
.catch((error) => {
console.error('Failed to update server settings', error)
})
},
setDeveloperMode() {
var value = !this.$store.state.developerMode
this.$store.commit('setDeveloperMode', value)
@@ -110,10 +147,6 @@ export default {
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')
@@ -140,6 +173,14 @@ export default {
})
}
},
clickAddUser() {
this.selectedAccount = null
this.showAccountModal = true
},
editUser(user) {
this.selectedAccount = user
this.showAccountModal = true
},
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(`Are you sure you want to permanently delete user "${user.username}"?`)) {
@@ -163,7 +204,7 @@ export default {
},
addUpdateUser(user) {
if (!this.users) return
var index = this.users.find((u) => u.id === user.id)
var index = this.users.findIndex((u) => u.id === user.id)
if (index >= 0) {
this.users.splice(index, 1, user)
} else {
@@ -186,6 +227,8 @@ export default {
this.$root.socket.on('user_added', this.addUpdateUser)
this.$root.socket.on('user_updated', this.addUpdateUser)
this.$root.socket.on('user_removed', this.userRemoved)
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
}
},
mounted() {
+3
View File
@@ -7,6 +7,9 @@
<script>
export default {
data() {
return {}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
+1 -1
View File
@@ -27,7 +27,7 @@ export default {
return {
error: null,
processing: false,
username: 'root',
username: '',
password: null
}
},
+258
View File
@@ -0,0 +1,258 @@
<template>
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
<main class="container mx-auto h-full max-w-screen-lg p-6">
<article class="max-h-full overflow-y-auto relative flex flex-col bg-primary shadow-xl rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="title" label="Title" />
</div>
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="author" label="Author" />
</div>
</div>
<div class="flex my-2 px-6">
<div class="w-1/2 px-2">
<ui-text-input-with-label v-model="series" label="Series" note="(optional)" />
</div>
<div class="w-1/2 px-2">
<div class="w-full">
<p class="px-1 text-sm font-semibold">Directory <em class="font-normal text-xs pl-2">(auto)</em></p>
<ui-text-input :value="directory" disabled class="w-full font-mono text-xs" style="height: 42px" />
</div>
</div>
</div>
<section v-if="showUploader" class="h-full overflow-auto p-8 w-full flex flex-col">
<header class="border-dashed border-2 border-gray-400 py-12 flex flex-col justify-center items-center relative h-40" :class="isDragOver ? 'bg-white bg-opacity-10' : ''">
<p v-show="isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop em'</p>
<p v-show="!isDragOver" class="mb-3 font-semibold text-gray-200 flex flex-wrap justify-center">Drop your audio and image files or</p>
<input ref="fileInput" id="hidden-input" type="file" multiple :accept="inputAccept" class="hidden" @change="inputChanged" />
<ui-btn @click="clickSelectAudioFiles">Select files</ui-btn>
<p class="text-xs text-gray-300 absolute bottom-3 right-3">{{ inputAccept.join(', ') }}</p>
</header>
</section>
<section v-else class="h-full overflow-auto px-8 pb-8 w-full flex flex-col">
<p v-if="!hasValidAudioFiles" class="text-error text-lg pt-4">* No valid audio tracks</p>
<div v-if="validImageFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Cover Image(s)</h1>
<div class="flex">
<template v-for="file in validImageFiles">
<div :key="file.name" class="h-28 w-20 bg-bg">
<img :src="file.src" class="h-full w-full object-contain" />
</div>
</template>
</div>
</div>
<div v-if="validAudioFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Audio Tracks</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in validAudioFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
<div v-if="invalidFiles.length">
<h1 class="pt-8 pb-3 font-semibold sm:text-lg text-gray-200">Invalid Files</h1>
<table class="text-sm tracksTable">
<tr class="font-book">
<th class="text-left">Filename</th>
<th class="text-left">Type</th>
<th class="text-left">Size</th>
</tr>
<template v-for="file in invalidFiles">
<tr :key="file.name">
<td class="font-book">
<p class="truncate">{{ file.name }}</p>
</td>
<td class="font-sm">
{{ file.type }}
</td>
<td class="font-mono">
{{ $bytesPretty(file.size) }}
</td>
</tr>
</template>
</table>
</div>
</section>
<footer v-show="!showUploader" class="flex justify-end px-8 pb-8 pt-4">
<ui-btn :disabled="!hasValidAudioFiles" color="success" @click="submit">Upload Audiobook</ui-btn>
<button id="cancel" class="ml-3 rounded-sm px-3 py-1 hover:bg-white hover:bg-opacity-10 focus:shadow-outline focus:outline-none" @click="cancel">Cancel</button>
</footer>
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center z-20">
<ui-loading-indicator text="Uploading..." />
</div>
</article>
</main>
</div>
</template>
<script>
import Path from 'path'
export default {
data() {
return {
processing: false,
title: null,
author: null,
series: null,
acceptedAudioFormats: ['.mp3', '.m4b', '.m4a'],
acceptedImageFormats: ['image/*'],
inputAccept: ['image/*, .mp3, .m4b, .m4a'],
isDragOver: false,
showUploader: true,
validAudioFiles: [],
validImageFiles: [],
invalidFiles: []
}
},
computed: {
streamAudiobook() {
return this.$store.state.streamAudiobook
},
hasValidAudioFiles() {
return this.validAudioFiles.length
},
directory() {
if (!this.author || !this.title) return ''
if (this.series) {
return Path.join('/audiobooks', this.author, this.series, this.title)
} else {
return Path.join('/audiobooks', this.author, this.title)
}
}
},
methods: {
reset() {
this.title = ''
this.author = ''
this.series = ''
this.cancel()
},
cancel() {
this.validAudioFiles = []
this.validImageFiles = []
this.invalidFiles = []
if (this.$refs.fileInput) {
this.$refs.fileInput.value = ''
}
this.showUploader = true
},
inputChanged(e) {
if (!e.target || !e.target.files) return
var _files = Array.from(e.target.files)
if (_files && _files.length) {
this.filesChanged(_files)
}
},
drop(evt) {
console.log('Dropped event', evt)
this.isDragOver = false
this.preventDefaults(evt)
const files = [...evt.dataTransfer.files]
this.filesChanged(files)
},
dragover(evt) {
console.log('Dragged over', evt)
this.isDragOver = true
this.preventDefaults(evt)
},
dragleave(evt) {
console.log('Dragged leave', evt)
this.isDragOver = false
this.preventDefaults(evt)
},
dragenter(evt) {
this.isDragOver = true
this.preventDefaults(evt)
},
preventDefaults(e) {
e.preventDefault()
e.stopPropagation()
},
filesChanged(files) {
console.log('FilesChanged', files)
this.showUploader = false
for (let i = 0; i < files.length; i++) {
var file = files[i]
var ext = Path.extname(file.name)
if (this.acceptedAudioFormats.includes(ext)) {
this.validAudioFiles.push(file)
} else if (file.type.startsWith('image/')) {
file.src = URL.createObjectURL(file)
this.validImageFiles.push(file)
} else {
this.invalidFiles.push(file)
}
}
},
clickSelectAudioFiles() {
if (this.$refs.fileInput) {
this.$refs.fileInput.click()
}
},
submit() {
if (!this.title || !this.author) {
this.$toast.error('Must enter a title and author')
return
}
this.processing = true
var form = new FormData()
form.set('title', this.title)
form.set('author', this.author)
form.set('series', this.series)
var index = 0
var files = this.validAudioFiles.concat(this.validImageFiles)
files.forEach((file) => {
form.set(`${index++}`, file)
})
this.$axios
.$post('/upload', form)
.then((data) => {
if (data.error) {
this.$toast.error(data.error)
} else {
this.$toast.success('Audiobook Uploaded Successfully')
this.reset()
}
this.processing = false
})
.catch((error) => {
console.error('Failed', error)
this.$toast.error('Oops, something went wrong...')
this.processing = false
})
}
},
mounted() {}
}
</script>
+1 -2
View File
@@ -1,17 +1,16 @@
export default function ({ $axios, store }) {
$axios.onRequest(config => {
console.log('Making request to ' + config.url)
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
return
}
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}`
}
if (process.env.NODE_ENV === 'development') {
config.url = `/dev${config.url}`
console.log('Making request to ' + config.url)
}
})
+14
View File
@@ -0,0 +1,14 @@
const DownloadStatus = {
PENDING: 0,
READY: 1,
EXPIRED: 2,
FAILED: 3
}
const Constants = {
DownloadStatus
}
export default ({ app }, inject) => {
inject('constants', Constants)
}
+27 -106
View File
@@ -38,115 +38,26 @@ 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.$calculateTextSize = (text, styles = {}) => {
const el = document.createElement('p')
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')
const c = document.createElement('canvas')
const ctx = c.getContext('2d')
img.onload = ({ target }) => {
c.width = target.naturalWidth
c.height = target.naturalHeight
ctx.drawImage(target, 0, 0)
c.toBlob((b) => resolve(b), 'image/jpeg', 0.75)
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
for (const key in styles) {
if (styles[key] && String(styles[key]).length > 0) {
attr += `${key}:${styles[key]};`
}
img.crossOrigin = ''
img.src = uri
})
}
}
Vue.prototype.$downloadImage = async (uri, name) => {
var blob = await loadImageBlob(uri)
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.target = '_blank'
a.download = name || 'fotosho-image'
a.click()
el.setAttribute('style', attr)
el.innerText = text
document.body.appendChild(el)
const boundingBox = el.getBoundingClientRect()
el.remove()
return {
height: boundingBox.height,
width: boundingBox.width
}
}
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
@@ -204,3 +115,13 @@ Vue.prototype.$sanitizeFilename = (input, replacement = '') => {
.replace(windowsTrailingRe, replacement);
return sanitized
}
const encode = (text) => encodeURIComponent(Buffer.from(text).toString('base64'))
Vue.prototype.$encode = encode
const decode = (text) => Buffer.from(decodeURIComponent(text), 'base64').toString()
Vue.prototype.$decode = decode
export {
encode,
decode
}
+7 -7
View File
@@ -1,10 +1,10 @@
import Vue from "vue";
import Toast from "vue-toastification";
import "vue-toastification/dist/index.css";
import Vue from "vue"
import Toast from "vue-toastification"
import "vue-toastification/dist/index.css"
const options = {
hideProgressBar: true
};
hideProgressBar: true,
draggable: false
}
Vue.use(Toast, options);
Vue.use(Toast, options)
+59
View File
@@ -0,0 +1,59 @@
import packagejson from '../package.json'
import axios from 'axios'
function parseSemver(ver) {
if (!ver) return null
var groups = ver.match(/^v((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/)
if (groups && groups.length > 6) {
var total = Number(groups[3]) * 100 + Number(groups[4]) * 10 + Number(groups[5])
if (isNaN(total)) {
console.warn('Invalid version total', groups[3], groups[4], groups[5])
return null
}
return {
total,
version: groups[2],
major: Number(groups[3]),
minor: Number(groups[4]),
patch: Number(groups[5]),
preRelease: groups[6] || null
}
} else {
console.warn('Invalid semver string', ver)
}
return null
}
export async function checkForUpdate() {
if (!packagejson.version) {
return
}
var currVerObj = parseSemver('v' + packagejson.version)
if (!currVerObj) {
console.error('Invalid version', packagejson.version)
return
}
var largestVer = null
await axios.get(`https://api.github.com/repos/advplyr/audiobookshelf/tags`).then((res) => {
var tags = res.data
if (tags && tags.length) {
tags.forEach((tag) => {
var verObj = parseSemver(tag.name)
if (verObj) {
if (!largestVer || largestVer.total < verObj.total) {
largestVer = verObj
}
}
})
}
})
if (!largestVer) {
console.error('No valid version tags to compare with')
return
}
return {
hasUpdate: largestVer.total > currVerObj.total,
latestVersion: largestVer.version,
githubTagUrl: `https://github.com/advplyr/audiobookshelf/releases/tag/v${largestVer.version}`,
currentVersion: currVerObj.version
}
}
+29 -6
View File
@@ -1,17 +1,21 @@
import { sort } from '@/assets/fastSort'
import { cleanFilterString } from '@/plugins/init.client'
import { decode } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
const STANDARD_GENRES = ['Adventure', 'Autobiography', 'Biography', 'Childrens', 'Comedy', 'Crime', 'Dystopian', 'Fantasy', 'Fiction', 'Health', 'History', 'Horror', 'Mystery', 'New Adult', 'Nonfiction', 'Philosophy', 'Politics', 'Religion', 'Romance', 'Sci-Fi', 'Self-Help', 'Short Story', 'Technology', 'Thriller', 'True Crime', 'Western', 'Young Adult']
export const state = () => ({
audiobooks: [],
listeners: [],
genres: [...STANDARD_GENRES],
tags: [],
series: []
series: [],
keywordFilter: null
})
export const getters = {
getAudiobook: (state) => id => {
return state.audiobooks.find(ab => ab.id === id)
},
getFiltered: (state, getters, rootState) => () => {
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
@@ -20,12 +24,20 @@ export const getters = {
var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = filterBy.replace(`${group}.`, '')
var filter = decode(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)
}
if (state.keywordFilter) {
const keywordFilterKeys = ['title', 'subtitle', 'author', 'series', 'narrarator']
const keyworkFilter = state.keywordFilter.toLowerCase()
return filtered.filter(ab => {
if (!ab.book) return false
return !!keywordFilterKeys.find(key => (ab.book[key] && ab.book[key].toLowerCase().includes(keyworkFilter)))
})
}
return filtered
},
getFilteredAndSorted: (state, getters, rootState) => () => {
@@ -33,14 +45,22 @@ export const getters = {
var direction = settings.orderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
var orderByNumber = settings.orderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
})
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)]
return [...new Set(_authors)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getGenresUsed: (state) => {
var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
}
}
@@ -64,6 +84,9 @@ export const actions = {
}
export const mutations = {
setKeywordFilter(state, val) {
state.keywordFilter = val
},
set(state, audiobooks) {
// GENRES
var genres = [...state.genres]
+3
View File
@@ -6,6 +6,9 @@ export const state = () => ({
export const getters = {
getDownloads: (state) => (audiobookId) => {
return state.downloads.filter(d => d.audiobookId === audiobookId)
},
getDownload: (state) => (id) => {
return state.downloads.find(d => d.id === id)
}
}
+48 -2
View File
@@ -1,7 +1,10 @@
import Vue from 'vue'
import { checkForUpdate } from '@/plugins/version'
export const state = () => ({
versionData: null,
serverSettings: null,
streamAudiobook: null,
editModalTab: 'details',
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
@@ -21,9 +24,43 @@ export const getters = {
getNumAudiobooksSelected: state => state.selectedAudiobooks.length
}
export const actions = {}
export const actions = {
updateServerSettings({ commit }, payload) {
var updatePayload = {
...payload
}
return this.$axios.$patch('/api/serverSettings', updatePayload).then((result) => {
if (result.success) {
commit('setServerSettings', result.settings)
return true
} else {
return false
}
}).catch((error) => {
console.error('Failed to update server settings', error)
return false
})
},
checkForUpdate({ commit }) {
return checkForUpdate()
.then((res) => {
commit('setVersionData', res)
return res
})
.catch((error) => {
console.error('Update check failed', error)
return false
})
}
}
export const mutations = {
setVersionData(state, versionData) {
state.versionData = versionData
},
setServerSettings(state, settings) {
state.serverSettings = settings
},
setStreamAudiobook(state, audiobook) {
state.playOnLoad = true
state.streamAudiobook = audiobook
@@ -42,9 +79,18 @@ export const mutations = {
state.playOnLoad = val
},
showEditModal(state, audiobook) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook
state.showEditModal = true
},
showEditModalOnTab(state, { audiobook, tab }) {
state.editModalTab = tab
state.selectedAudiobook = audiobook
state.showEditModal = true
},
setEditModalTab(state, tab) {
state.editModalTab = tab
},
setShowEditModal(state, val) {
state.showEditModal = val
},
+9 -1
View File
@@ -24,6 +24,15 @@ export const getters = {
},
getFilterOrderKey: (state) => {
return Object.values(state.settings).join('-')
},
getUserCanUpdate: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.update : false
},
getUserCanDelete: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.delete : false
},
getUserCanDownload: (state) => {
return state.user && state.user.permissions ? !!state.user.permissions.download : false
}
}
@@ -35,7 +44,6 @@ export const actions = {
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
+2 -1
View File
@@ -5,7 +5,8 @@ module.exports = {
options: {
safelist: [
'bg-success',
'bg-red-600'
'bg-red-600',
'py-1.5'
]
}
},
+1 -1
View File
@@ -10,7 +10,7 @@
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<Project>https://github.com/advplyr/audiobookshelf</Project>
<Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free &amp; open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview>
<Category>MediaApp:Books MediaServer:Books Status:Beta</Category>
<Category>MediaApp:Books MediaServer:Books</Category>
<WebUI>http://[IP]:[PORT:80]</WebUI>
<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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+311 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "0.9.76-beta",
"version": "1.1.7",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -83,6 +83,53 @@
"negotiator": "0.6.2"
}
},
"archiver": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.0.tgz",
"integrity": "sha512-iUw+oDwK0fgNpvveEsdQ0Ase6IIKztBJU2U0E9MzszMfmVVUyv1QJhS2ITW9ZCqx8dktAxVAjWWkKehuZE8OPg==",
"requires": {
"archiver-utils": "^2.1.0",
"async": "^3.2.0",
"buffer-crc32": "^0.2.1",
"readable-stream": "^3.6.0",
"readdir-glob": "^1.0.0",
"tar-stream": "^2.2.0",
"zip-stream": "^4.1.0"
}
},
"archiver-utils": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz",
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"requires": {
"glob": "^7.1.4",
"graceful-fs": "^4.2.0",
"lazystream": "^1.0.0",
"lodash.defaults": "^4.2.0",
"lodash.difference": "^4.5.0",
"lodash.flatten": "^4.4.0",
"lodash.isplainobject": "^4.0.6",
"lodash.union": "^4.6.0",
"normalize-path": "^3.0.0",
"readable-stream": "^2.0.0"
},
"dependencies": {
"readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
}
}
},
"are-shallow-equal": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/are-shallow-equal/-/are-shallow-equal-1.1.1.tgz",
@@ -124,6 +171,11 @@
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-0.1.4.tgz",
"integrity": "sha1-mBjHngWbE1X5fgQooBfIOOkLqBI="
},
"base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
@@ -134,6 +186,23 @@
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms="
},
"bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"requires": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
},
"dependencies": {
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
}
}
},
"body-parser": {
"version": "1.19.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.19.0.tgz",
@@ -160,11 +229,33 @@
"concat-map": "0.0.1"
}
},
"buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
"integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI="
},
"buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk="
},
"busboy": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz",
"integrity": "sha512-y7tTxhGKXcyBxRKAni+awqx8uqaJKrSFSNFSeRG5CsWNdmy2BIK+6VGWEW7TZnIO/533mtMEA4rOevQV815YJw==",
"requires": {
"dicer": "0.3.0"
}
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
@@ -202,6 +293,17 @@
"resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz",
"integrity": "sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg=="
},
"compress-commons": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.1.tgz",
"integrity": "sha512-QLdDLCKNV2dtoTorqgxngQCMA+gWXkM/Nwu7FpeBhk/RdkzimqC3jueb/FDmaZeXh+uby1jkBqE3xArsLBE5wQ==",
"requires": {
"buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2",
"normalize-path": "^3.0.0",
"readable-stream": "^3.6.0"
}
},
"concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -239,6 +341,11 @@
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha1-4wOogrNCzD7oylE6eZmXNNqzriw="
},
"core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@@ -248,6 +355,24 @@
"vary": "^1"
}
},
"crc-32": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.0.tgz",
"integrity": "sha512-1uBwHxF+Y/4yF5G48fwnKq6QsIXheor3ZLPT80yGBV1oEUwpPojlEhQbWKVw1VwcTQyMGHK1/XMmTjmlsmTTGA==",
"requires": {
"exit-on-epipe": "~1.0.1",
"printj": "~1.1.0"
}
},
"crc32-stream": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.2.tgz",
"integrity": "sha512-DxFZ/Hk473b/muq1VJ///PMNLj0ZMnzye9thBpmjpJKCc5eMgB95aK8zCGrGfQ90cWo561Te6HK9D+j4KPdM6w==",
"requires": {
"crc-32": "^1.2.0",
"readable-stream": "^3.4.0"
}
},
"debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -291,6 +416,14 @@
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz",
"integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA="
},
"dicer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/dicer/-/dicer-0.3.0.tgz",
"integrity": "sha512-MdceRRWqltEG2dZqO769g27N/3PXfcKl04VhYnBlo2YhH7zPi88VebsjTKclaOyiuMaGU72hTfw3VkUitGcVCA==",
"requires": {
"streamsearch": "0.1.2"
}
},
"ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -369,6 +502,11 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
"exit-on-epipe": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
"integrity": "sha512-h2z5mrROTxce56S+pnvAV890uu7ls7f1kEvVGJbw1OlFH3/mlJ5bkXu0KRyW94v37zzHPiUd55iLn3DA7TjWpw=="
},
"express": {
"version": "4.17.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.17.1.tgz",
@@ -406,6 +544,14 @@
"vary": "~1.1.2"
}
},
"express-fileupload": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/express-fileupload/-/express-fileupload-1.2.1.tgz",
"integrity": "sha512-fWPNAkBj+Azt9Itmcz/Reqdg3LeBfaXptDEev2JM8bCC0yDptglCnlizhf0YZauyU5X/g6v7v4Xxqhg8tmEfEA==",
"requires": {
"busboy": "^0.3.1"
}
},
"finalhandler": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz",
@@ -444,6 +590,11 @@
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
"integrity": "sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac="
},
"fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="
},
"fs-extra": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.0.0.tgz",
@@ -454,6 +605,11 @@
"universalify": "^2.0.0"
}
},
"fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
},
"get-stream": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@@ -462,6 +618,19 @@
"pump": "^3.0.0"
}
},
"glob": {
"version": "7.1.7",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
"integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"got": {
"version": "11.3.0",
"resolved": "https://registry.npmjs.org/got/-/got-11.3.0.tgz",
@@ -520,6 +689,20 @@
"safer-buffer": ">= 2.1.2 < 3"
}
},
"ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
"requires": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"inherits": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
@@ -540,6 +723,11 @@
"resolved": "https://registry.npmjs.org/is-primitive/-/is-primitive-3.0.1.tgz",
"integrity": "sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w=="
},
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
"isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -610,6 +798,30 @@
"json-buffer": "3.0.1"
}
},
"lazystream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.0.tgz",
"integrity": "sha1-9plf4PggOS9hOWvolGJAe7dxaOQ=",
"requires": {
"readable-stream": "^2.0.5"
},
"dependencies": {
"readable-stream": {
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
}
}
},
"libgen": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/libgen/-/libgen-2.1.0.tgz",
@@ -618,6 +830,21 @@
"got": "11.3.x"
}
},
"lodash.defaults": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
},
"lodash.difference": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha1-nMtOUF1Ia5FlE0V3KIWi3yf9AXw="
},
"lodash.flatten": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -653,6 +880,11 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
"lodash.union": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha1-SLtQiECfFvGCFmZkHETdGqrjzYg="
},
"lowercase-keys": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz",
@@ -730,6 +962,11 @@
"minimatch": "^3.0.2"
}
},
"normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
},
"normalize-url": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz",
@@ -766,6 +1003,11 @@
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-to-regexp": {
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@@ -779,6 +1021,16 @@
"rss": "^1.2.2"
}
},
"printj": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/printj/-/printj-1.1.2.tgz",
"integrity": "sha512-zA2SmoLaxZyArQTOPj5LXecR+RagfPSU5Kw1qP+jkWeNlrq+eJZyY2oS68SU1Z/7/myXM4lo9716laOFAVStCQ=="
},
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"promise-concurrency-limiter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/promise-concurrency-limiter/-/promise-concurrency-limiter-1.0.0.tgz",
@@ -838,6 +1090,24 @@
"unpipe": "1.0.0"
}
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
},
"readdir-glob": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.1.tgz",
"integrity": "sha512-91/k1EzZwDx6HbERR+zucygRFfiPl2zkIYZtv3Jjr6Mn7SkKcVct8aVO+sSRiGMc6fLf72du3d92/uY63YPdEA==",
"requires": {
"minimatch": "^3.0.4"
}
},
"resolve-alpn": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.0.tgz",
@@ -1017,11 +1287,36 @@
"resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz",
"integrity": "sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow="
},
"streamsearch": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-0.1.2.tgz",
"integrity": "sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo="
},
"string-indexes": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/string-indexes/-/string-indexes-1.0.0.tgz",
"integrity": "sha512-RUlx+2YydZJNlRAvoh1siPYWj/Xfk6t1sQLkA5n1tMGRCKkRLzkRtJhHk4qRmKergEBh8R3pWhsUsDqia/bolw=="
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
}
},
"tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"requires": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
}
},
"tiny-readdir": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-1.5.0.tgz",
@@ -1054,6 +1349,11 @@
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw="
},
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1099,6 +1399,16 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
"integrity": "sha1-eLpyAgApxbyHuKgaPPzXS0ovweU="
},
"zip-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.0.tgz",
"integrity": "sha512-zshzwQW7gG7hjpBlgeQP9RuyPGNxvJdzR8SUM3QhxCnLjWN2E7j3dOvpeDcQoETfHx0urRS7EtmVToql7YpU4A==",
"requires": {
"archiver-utils": "^2.1.0",
"compress-commons": "^4.1.0",
"readable-stream": "^3.6.0"
}
}
}
}
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.0.0",
"version": "1.1.12",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {
@@ -10,10 +10,12 @@
"author": "advplyr",
"license": "ISC",
"dependencies": {
"archiver": "^5.3.0",
"axios": "^0.21.1",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.5",
"express": "^4.17.1",
"express-fileupload": "^1.2.1",
"fluent-ffmpeg": "^2.1.2",
"fs-extra": "^10.0.0",
"ip": "^1.1.5",
+6 -4
View File
@@ -17,16 +17,18 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
/Author/Series/Title/...
Title can start with the publish year like so:
/1989 - Awesome Book/...
/1989 - Book Title/...
(Optional Setting) Subtitle can be seperated to its own field:
/Book Title - With a Subtitle/...
/1989 - Book Title - With a Subtitle/...
will store "With a Subtitle" as the subtitle
```
#### Features coming soon:
* 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
* 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_audiobook.png" />
+131 -13
View File
@@ -4,7 +4,7 @@ const User = require('./objects/User')
const { isObject } = require('./utils/index')
class ApiController {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter) {
constructor(db, scanner, auth, streamManager, rssFeeds, downloadManager, emitter, clientEmitter) {
this.db = db
this.scanner = scanner
this.auth = auth
@@ -12,6 +12,7 @@ class ApiController {
this.rssFeeds = rssFeeds
this.downloadManager = downloadManager
this.emitter = emitter
this.clientEmitter = clientEmitter
this.router = express()
this.init()
@@ -34,12 +35,18 @@ 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/audiobook/:id', this.updateUserAudiobookProgress.bind(this))
this.router.patch('/user/audiobooks', this.batchUpdateUserAudiobooksProgress.bind(this))
this.router.patch('/user/password', this.userChangePassword.bind(this))
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
this.router.get('/users', this.getUsers.bind(this))
this.router.post('/user', this.createUser.bind(this))
this.router.patch('/user/:id', this.updateUser.bind(this))
this.router.delete('/user/:id', this.deleteUser.bind(this))
this.router.patch('/serverSettings', this.updateServerSettings.bind(this))
this.router.post('/authorize', this.authorize.bind(this))
@@ -84,6 +91,10 @@ class ApiController {
}
async deleteAllAudiobooks(req, res) {
if (!req.user.isRoot) {
Logger.warn('User other than root attempted to delete all audiobooks', req.user)
return res.sendStatus(403)
}
Logger.info('Removing all Audiobooks')
var success = await this.db.recreateAudiobookDb()
if (success) res.sendStatus(200)
@@ -100,7 +111,7 @@ class ApiController {
// Remove audiobook from users
for (let i = 0; i < this.db.users.length; i++) {
var user = this.db.users[i]
var madeUpdates = user.resetAudiobookProgress(audiobook.id)
var madeUpdates = user.deleteAudiobookProgress(audiobook.id)
if (madeUpdates) {
await this.db.updateEntity('user', user)
}
@@ -125,6 +136,10 @@ class ApiController {
}
async deleteAudiobook(req, res) {
if (!req.user.canDelete) {
Logger.warn('User attempted to delete without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
@@ -133,6 +148,10 @@ class ApiController {
}
async batchDeleteAudiobooks(req, res) {
if (!req.user.canDelete) {
Logger.warn('User attempted to delete without permission', req.user)
return res.sendStatus(403)
}
var { audiobookIds } = req.body
if (!audiobookIds || !audiobookIds.length) {
return res.sendStatus(500)
@@ -150,6 +169,10 @@ class ApiController {
}
async batchUpdateAudiobooks(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to batch update without permission', req.user)
return res.sendStatus(403)
}
var audiobooks = req.body
if (!audiobooks || !audiobooks.length) {
return res.sendStatus(500)
@@ -180,17 +203,25 @@ class ApiController {
}
async updateAudiobookTracks(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update audiotracks without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var files = req.body.files
var orderedFileData = req.body.orderedFileData
Logger.info(`Updating audiobook tracks called ${audiobook.id}`)
audiobook.updateAudioTracks(files)
audiobook.updateAudioTracks(orderedFileData)
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
res.json(audiobook.toJSON())
}
async updateAudiobook(req, res) {
if (!req.user.canUpdate) {
Logger.warn('User attempted to update without permission', req.user)
return res.sendStatus(403)
}
var audiobook = this.db.audiobooks.find(a => a.id === req.params.id)
if (!audiobook) return res.sendStatus(404)
var hasUpdates = audiobook.update(req.body)
@@ -229,7 +260,36 @@ class ApiController {
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
await this.db.updateEntity('user', req.user)
this.emitter('user_updated', req.user.toJSONForBrowser())
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
res.sendStatus(200)
}
async updateUserAudiobookProgress(req, res) {
var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body)
if (wasUpdated) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
}
async batchUpdateUserAudiobooksProgress(req, res) {
var abProgresses = req.body
if (!abProgresses || !abProgresses.length) {
return res.sendStatus(500)
}
var shouldUpdate = false
abProgresses.forEach((progress) => {
var wasUpdated = req.user.updateAudiobookProgress(progress.audiobookId, progress)
if (wasUpdated) shouldUpdate = true
})
if (shouldUpdate) {
await this.db.updateEntity('user', req.user)
this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
}
res.sendStatus(200)
}
@@ -262,6 +322,10 @@ class ApiController {
}
async createUser(req, res) {
if (!req.user.isRoot) {
Logger.warn('Non-root user attempted to create user', req.user)
return res.sendStatus(403)
}
var account = req.body
account.id = (Math.trunc(Math.random() * 1000) + Date.now()).toString(36)
account.pash = await this.auth.hashPass(account.password)
@@ -271,7 +335,7 @@ class ApiController {
var newUser = new User(account)
var success = await this.db.insertUser(newUser)
if (success) {
this.emitter('user_added', newUser)
this.clientEmitter(req.user.id, 'user_added', newUser)
res.json({
user: newUser.toJSONForBrowser()
})
@@ -282,7 +346,41 @@ class ApiController {
}
}
async updateUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update user', req.user)
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
var account = req.body
// Updating password
if (account.password) {
account.pash = await this.auth.hashPass(account.password)
delete account.password
}
var hasUpdated = user.update(account)
if (hasUpdated) {
await this.db.updateEntity('user', user)
}
this.clientEmitter(req.user.id, 'user_updated', user.toJSONForBrowser())
res.json({
success: true,
user: user.toJSONForBrowser()
})
}
async deleteUser(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to delete user', req.user)
return res.sendStatus(403)
}
if (req.params.id === 'root') {
return res.sendStatus(500)
}
@@ -302,13 +400,36 @@ class ApiController {
var userJson = user.toJSONForBrowser()
await this.db.removeEntity('user', user.id)
this.emitter('user_removed', userJson)
this.clientEmitter(req.user.id, 'user_removed', userJson)
res.json({
success: true
})
}
async updateServerSettings(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update server settings', req.user)
return res.sendStatus(403)
}
var settingsUpdate = req.body
if (!settingsUpdate || !isObject(settingsUpdate)) {
return res.sendStatus(500)
}
var madeUpdates = this.db.serverSettings.update(settingsUpdate)
if (madeUpdates) {
await this.db.updateEntity('settings', this.db.serverSettings)
}
return res.json({
success: true,
serverSettings: this.db.serverSettings
})
}
async download(req, res) {
if (!req.user.canDownload) {
Logger.error('User attempting to download without permission', req.user)
return res.sendStatus(403)
}
var downloadId = req.params.id
Logger.info('Download Request', downloadId)
var download = this.downloadManager.getDownload(downloadId)
@@ -319,12 +440,9 @@ class ApiController {
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)
+9 -1
View File
@@ -48,6 +48,10 @@ class Auth {
var user = await this.verifyToken(token)
if (!user) {
Logger.error('Verify Token User Not Found', token)
return res.sendStatus(404)
}
if (!user.isActive) {
Logger.error('Verify Token User is disabled', token, user.username)
return res.sendStatus(403)
}
req.user = user
@@ -68,7 +72,7 @@ class Auth {
}
generateAccessToken(payload) {
return jwt.sign(payload, process.env.TOKEN_SECRET, { expiresIn: '1800s' });
return jwt.sign(payload, process.env.TOKEN_SECRET);
}
verifyToken(token) {
@@ -95,6 +99,10 @@ class Auth {
return res.json({ error: 'User not found' })
}
if (!user.isActive) {
return res.json({ error: 'User unavailable' })
}
// Check passwordless root user
if (user.id === 'root' && (!user.pash || user.pash === '')) {
if (password) {
+26 -12
View File
@@ -4,6 +4,7 @@ const jwt = require('jsonwebtoken')
const Logger = require('./Logger')
const Audiobook = require('./objects/Audiobook')
const User = require('./objects/User')
const ServerSettings = require('./objects/ServerSettings')
class Db {
constructor(CONFIG_PATH) {
@@ -19,6 +20,8 @@ class Db {
this.users = []
this.audiobooks = []
this.settings = []
this.serverSettings = null
}
getEntityDb(entityName) {
@@ -39,15 +42,6 @@ class Db {
return 'settings'
}
getDefaultSettings() {
return {
config: {
version: 1,
cardSize: 'md'
}
}
}
getDefaultUser(token) {
return new User({
id: 'root',
@@ -71,23 +65,43 @@ class Db {
Logger.debug('Generated default token', token)
await this.insertUser(this.getDefaultUser(token))
}
if (!this.serverSettings) {
this.serverSettings = new ServerSettings()
await this.insertSettings(this.serverSettings)
}
}
async load() {
var p1 = this.audiobooksDb.select(() => true).then((results) => {
this.audiobooks = results.data.map(a => new Audiobook(a))
Logger.info(`Audiobooks Loaded ${this.audiobooks.length}`)
Logger.info(`[DB] Audiobooks Loaded ${this.audiobooks.length}`)
})
var p2 = this.usersDb.select(() => true).then((results) => {
this.users = results.data.map(u => new User(u))
Logger.info(`Users Loaded ${this.users.length}`)
Logger.info(`[DB] Users Loaded ${this.users.length}`)
})
var p3 = this.settingsDb.select(() => true).then((results) => {
this.settings = results
if (results.data && results.data.length) {
this.settings = results.data
var serverSettings = this.settings.find(s => s.id === 'server-settings')
if (serverSettings) {
this.serverSettings = new ServerSettings(serverSettings)
}
}
})
await Promise.all([p1, p2, p3])
}
insertSettings(settings) {
return this.settingsDb.insert([settings]).then((results) => {
Logger.debug(`[DB] Inserted ${results.inserted} settings`)
this.settings = this.settings.concat(settings)
}).catch((error) => {
Logger.error(`[DB] Insert settings Failed ${error}`)
})
}
insertAudiobook(audiobook) {
return this.insertAudiobooks([audiobook])
}
+194 -44
View File
@@ -1,16 +1,18 @@
const Path = require('path')
const fs = require('fs-extra')
const archiver = require('archiver')
const workerThreads = require('worker_threads')
const Logger = require('./Logger')
const Download = require('./objects/Download')
const { writeConcatFile } = require('./utils/ffmpegHelpers')
const { writeConcatFile, writeMetadataFile } = require('./utils/ffmpegHelpers')
const { getFileSize } = require('./utils/fileUtils')
class DownloadManager {
constructor(db, MetadataPath, emitter) {
constructor(db, MetadataPath, AudiobookPath, emitter) {
this.db = db
this.MetadataPath = MetadataPath
this.AudiobookPath = AudiobookPath
this.emitter = emitter
this.downloadDirPath = Path.join(this.MetadataPath, 'downloads')
@@ -49,12 +51,13 @@ class DownloadManager {
this.prepareDownload(client, audiobook, options)
}
getBestFileType(tracks) {
if (!tracks || !tracks.length) {
return null
removeSocketRequest(socket, downloadId) {
var download = this.downloads.find(d => d.id === downloadId)
if (!download) {
Logger.error('Remove download request download not found ' + downloadId)
return
}
var firstTrack = tracks[0]
return firstTrack.ext.substr(1)
this.removeDownload(download)
}
async prepareDownload(client, audiobook, options = {}) {
@@ -67,26 +70,29 @@ class DownloadManager {
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)
var audioFileType = options.audioFileType || '.m4b'
delete options.audioFileType
filename = audiobookDirname + '.' + audioFileType
fileext = '.' + audioFileType
filepath = Path.join(dlpath, filename)
if (audioFileType === 'same') {
var firstTrack = audiobook.tracks[0]
audioFileType = firstTrack.ext
}
fileext = audioFileType
} else if (downloadType === 'zip') {
fileext = '.zip'
}
var filename = audiobookDirname + fileext
var downloadData = {
id: downloadId,
audiobookId: audiobook.id,
type: downloadType,
options: options,
dirpath: dlpath,
fullPath: filepath,
fullPath: Path.join(dlpath, filename),
filename,
ext: fileext,
userId: (client && client.user) ? client.user.id : null,
@@ -94,6 +100,7 @@ class DownloadManager {
}
var download = new Download()
download.setData(downloadData)
download.setTimeoutTimer(this.downloadTimedOut.bind(this))
if (downloadData.socket) {
downloadData.socket.emit('download_started', download.toJSON())
@@ -101,30 +108,165 @@ class DownloadManager {
if (download.type === 'singleAudio') {
this.processSingleAudioDownload(audiobook, download)
} else if (download.type === 'zip') {
this.processZipDownload(audiobook, download)
}
}
async processZipDownload(audiobook, download) {
this.pendingDownloads.push({
id: download.id,
download
})
Logger.info(`[DownloadManager] Processing Zip download ${download.fullPath}`)
var success = await this.zipAudiobookDir(audiobook.fullPath, download.fullPath).then(() => {
return true
}).catch((error) => {
Logger.error('[DownloadManager] Process Zip Failed', error)
return false
})
this.sendResult(download, { success })
}
zipAudiobookDir(audiobookPath, downloadPath) {
return new Promise((resolve, reject) => {
// create a file to stream archive data to
const output = fs.createWriteStream(downloadPath)
const archive = archiver('zip', {
zlib: { level: 9 } // Sets the compression level.
})
// listen for all archive data to be written
// 'close' event is fired only when a file descriptor is involved
output.on('close', () => {
Logger.info(archive.pointer() + ' total bytes')
Logger.debug('archiver has been finalized and the output file descriptor has closed.')
resolve()
})
// This event is fired when the data source is drained no matter what was the data source.
// It is not part of this library but rather from the NodeJS Stream API.
// @see: https://nodejs.org/api/stream.html#stream_event_end
output.on('end', () => {
Logger.debug('Data has been drained')
})
// good practice to catch warnings (ie stat failures and other non-blocking errors)
archive.on('warning', function (err) {
if (err.code === 'ENOENT') {
// log warning
Logger.warn(`[DownloadManager] Archiver warning: ${err.message}`)
} else {
// throw error
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
// throw err
reject(err)
}
})
archive.on('error', function (err) {
Logger.error(`[DownloadManager] Archiver error: ${err.message}`)
reject(err)
})
// pipe archive data to the file
archive.pipe(output)
archive.directory(audiobookPath, false)
archive.finalize()
})
}
async processSingleAudioDownload(audiobook, download) {
// var ffmpeg = Ffmpeg()
var concatFilePath = Path.join(download.dirpath, 'files.txt')
await writeConcatFile(audiobook.tracks, concatFilePath)
// If changing audio file type then encoding is needed
var audioRequiresEncode = audiobook.tracks[0].ext !== download.ext
var shouldIncludeCover = download.includeCover && audiobook.book.cover
var firstTrackIsM4b = audiobook.tracks[0].ext.toLowerCase() === '.m4b'
var isOneTrack = audiobook.tracks.length === 1
const ffmpegInputs = []
if (!isOneTrack) {
var concatFilePath = Path.join(download.dirpath, 'files.txt')
await writeConcatFile(audiobook.tracks, concatFilePath)
ffmpegInputs.push({
input: concatFilePath,
options: ['-safe 0', '-f concat']
})
} else {
ffmpegInputs.push({
input: audiobook.tracks[0].fullPath,
options: firstTrackIsM4b ? ['-f mp4'] : []
})
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
var ffmpegOptions = [`-loglevel ${logLevel}`]
var ffmpegOutputOptions = []
if (audioRequiresEncode) {
ffmpegOptions = ffmpegOptions.concat([
'-map 0:a',
'-acodec aac',
'-ac 2',
'-b:a 64k',
'-id3v2_version 3'
])
} else {
ffmpegOptions.push('-max_muxing_queue_size 1000')
if (isOneTrack && firstTrackIsM4b && !shouldIncludeCover) {
ffmpegOptions.push('-c copy')
} else {
ffmpegOptions.push('-c:a copy')
}
}
if (download.ext === '.m4b') {
Logger.info('Concat m4b\'s use -f mp4')
ffmpegOutputOptions.push('-f mp4')
}
if (download.includeMetadata) {
var metadataFilePath = Path.join(download.dirpath, 'metadata.txt')
await writeMetadataFile(audiobook, metadataFilePath)
ffmpegInputs.push({
input: metadataFilePath
})
ffmpegOptions.push('-map_metadata 1')
}
if (shouldIncludeCover) {
var _cover = audiobook.book.cover
if (_cover.startsWith(Path.sep + 'local')) {
_cover = Path.join(this.AudiobookPath, _cover.replace(Path.sep + 'local', ''))
Logger.debug('Local cover url', _cover)
}
ffmpegInputs.push({
input: _cover,
options: ['-f image2pipe']
})
ffmpegOptions.push('-vf [2:v]crop=trunc(iw/2)*2:trunc(ih/2)*2')
ffmpegOptions.push('-map 2:v')
}
var workerData = {
input: concatFilePath,
inputFormat: 'concat',
inputOption: '-safe 0',
options: [
'-loglevel warning',
'-map 0:a',
'-c:a copy'
],
output: download.fullPath
inputs: ffmpegInputs,
options: ffmpegOptions,
outputOptions: ffmpegOutputOptions,
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)
if (!download.isTimedOut) {
this.sendResult(download, message)
}
}
} else {
Logger.error('Invalid worker message', message)
@@ -137,6 +279,17 @@ class DownloadManager {
})
}
async downloadTimedOut(download) {
Logger.info(`[DownloadManager] Download ${download.id} timed out (${download.timeoutTimeMs}ms)`)
if (download.socket) {
var downloadJson = download.toJSON()
downloadJson.isTimedOut = true
download.socket.emit('download_failed', downloadJson)
}
this.removeDownload(download)
}
async downloadExpired(download) {
Logger.info(`[DownloadManager] Download ${download.id} expired`)
@@ -147,6 +300,8 @@ class DownloadManager {
}
async sendResult(download, result) {
download.clearTimeoutTimer()
// Remove pending download
this.pendingDownloads = this.pendingDownloads.filter(d => d.id !== download.id)
@@ -165,18 +320,8 @@ class DownloadManager {
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)
var filesize = await getFileSize(download.fullPath)
download.setComplete(filesize)
if (download.socket) {
download.socket.emit('download_ready', download.toJSON())
}
@@ -189,15 +334,20 @@ class DownloadManager {
async removeDownload(download) {
Logger.info('[DownloadManager] Removing download ' + download.id)
download.clearTimeoutTimer()
download.clearExpirationTimer()
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)
if (pendingDl.worker) {
try {
pendingDl.worker.postMessage('STOP')
} catch (error) {
Logger.error('[DownloadManager] Error posting stop message to worker', error)
}
}
}
+222 -113
View File
@@ -1,10 +1,13 @@
const fs = require('fs-extra')
const Path = require('path')
const Logger = require('./Logger')
const BookFinder = require('./BookFinder')
const Audiobook = require('./objects/Audiobook')
const audioFileScanner = require('./utils/audioFileScanner')
const { getAllAudiobookFiles } = require('./utils/scandir')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('./utils/scandir')
const { comparePaths, getIno } = require('./utils/index')
const { secondsToTimestamp } = require('./utils/fileUtils')
const { ScanResult } = require('./utils/constants')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
@@ -60,27 +63,143 @@ class Scanner {
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
}
async scan() {
// TEMP - fix relative file paths
// TEMP - update ino for each audiobook
if (this.audiobooks.length) {
for (let i = 0; i < this.audiobooks.length; i++) {
var ab = this.audiobooks[i]
var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
async scanAudiobookData(audiobookData) {
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
// 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)
}
if (existingAudiobook) {
// REMOVE: No valid audio files
// TODO: Label as incomplete, do not actually delete
if (!audiobookData.audioFiles.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
return ScanResult.REMOVED
}
audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles)
// Check for audio files that were removed
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
if (removedAudioFiles.length) {
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
}
// Check for new audio files and sync existing audio files
var newAudioFiles = []
var hasUpdatedAudioFiles = false
audiobookData.audioFiles.forEach((file) => {
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
if (existingAudioFile) { // Audio file exists, sync paths
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
hasUpdatedAudioFiles = true
}
} 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 - sets tracks
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
}
// REMOVE: No valid audio tracks
// TODO: Label as incomplete, do not actually delete
if (!existingAudiobook.tracks.length) {
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
await this.db.removeEntity('audiobook', existingAudiobook.id)
this.emitter('audiobook_removed', existingAudiobook.toJSONMinified())
return ScanResult.REMOVED
}
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 (existingAudiobook.isMissing) {
existingAudiobook.isMissing = false
hasUpdates = true
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
}
if (hasUpdates) {
existingAudiobook.setChapters()
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now()
await this.db.updateAudiobook(existingAudiobook)
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
return ScanResult.UPDATED
}
return ScanResult.UPTODATE
}
// NEW: Check new audiobook
if (!audiobookData.audioFiles.length) {
Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path)
return ScanResult.NOTHING
}
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
return ScanResult.NOTHING
}
audiobook.checkUpdateMissingParts()
audiobook.setChapters()
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
await this.db.insertAudiobook(audiobook)
this.emitter('audiobook_added', audiobook.toJSONMinified())
return ScanResult.ADDED
}
async scan() {
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
// TEMP - fix relative file paths
// TEMP - update ino for each audiobook
// if (this.audiobooks.length) {
// for (let i = 0; i < this.audiobooks.length; i++) {
// var ab = this.audiobooks[i]
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
// // 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)
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
// Set ino for each ab data as a string
audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound)
@@ -93,18 +212,21 @@ class Scanner {
var scanResults = {
removed: 0,
updated: 0,
added: 0
added: 0,
missing: 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)
var audiobook = this.audiobooks[i]
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.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)
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
audiobook.isMissing = true
audiobook.lastUpdate = Date.now()
scanResults.missing++
await this.db.updateAudiobook(audiobook)
this.emitter('audiobook_updated', audiobook.toJSONMinified())
}
if (this.cancelScan) {
this.cancelScan = false
@@ -112,97 +234,14 @@ class Scanner {
}
}
// Check for new and updated audiobooks
for (let i = 0; i < audiobookDataFound.length; i++) {
var audiobookData = audiobookDataFound[i]
var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino)
Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`)
var result = await this.scanAudiobookData(audiobookData)
if (result === ScanResult.ADDED) scanResults.added++
if (result === ScanResult.REMOVED) scanResults.removed++
if (result === ScanResult.UPDATED) scanResults.updated++
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 {
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
if (!audiobook.tracks.length) {
Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title)
} else {
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++
}
} // end if add new
}
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', {
scanType: 'files',
@@ -218,10 +257,80 @@ class Scanner {
}
}
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
return scanResults
}
async scanAudiobook(audiobookPath) {
Logger.debug('[Scanner] scanAudiobook', audiobookPath)
var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings)
if (!audiobookData) {
return ScanResult.NOTHING
}
audiobookData.ino = await getIno(audiobookData.fullPath)
return this.scanAudiobookData(audiobookData)
}
// Files were modified in this directory, check it out
async checkDir(dir) {
var exists = await fs.pathExists(dir)
if (!exists) {
// Audiobook was deleted, TODO: Should confirm this better
var audiobook = this.db.audiobooks.find(ab => ab.fullPath === dir)
if (audiobook) {
var audiobookJSON = audiobook.toJSONMinified()
await this.db.removeEntity('audiobook', audiobook.id)
this.emitter('audiobook_removed', audiobookJSON)
return ScanResult.REMOVED
}
// Path inside audiobook was deleted, scan audiobook
audiobook = this.db.audiobooks.find(ab => dir.startsWith(ab.fullPath))
if (audiobook) {
Logger.info(`[Scanner] Path inside audiobook "${audiobook.title}" was deleted: ${dir}`)
return this.scanAudiobook(audiobook.fullPath)
}
Logger.warn('[Scanner] Path was deleted but no audiobook found', dir)
return ScanResult.NOTHING
}
// Check if this is a subdirectory of an audiobook
var audiobook = this.db.audiobooks.find((ab) => dir.startsWith(ab.fullPath))
if (audiobook) {
Logger.debug(`[Scanner] Check Dir audiobook "${audiobook.title}" found: ${dir}`)
return this.scanAudiobook(audiobook.fullPath)
}
// Check if an audiobook is a subdirectory of this dir
audiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(dir))
if (audiobook) {
Logger.warn(`[Scanner] Files were added/updated in a root directory of an existing audiobook, ignore files: ${dir}`)
return ScanResult.NOTHING
}
// Must be a new audiobook
Logger.debug(`[Scanner] Check Dir must be a new audiobook: ${dir}`)
return this.scanAudiobook(dir)
}
// Array of files that may have been renamed, removed or added
async filesChanged(filepaths) {
if (!filepaths.length) return ScanResult.NOTHING
var relfilepaths = filepaths.map(path => path.replace(this.AudiobookPath, ''))
var fileGroupings = groupFilesIntoAudiobookPaths(relfilepaths)
var results = []
for (const dir in fileGroupings) {
Logger.debug(`[Scanner] Check dir ${dir}`)
var fullPath = Path.join(this.AudiobookPath, dir)
var result = await this.checkDir(fullPath)
Logger.debug(`[Scanner] Check dir result ${result}`)
results.push(result)
}
return results
}
async fetchMetadata(id, trackIndex = 0) {
var audiobook = this.audiobooks.find(a => a.id === id)
if (!audiobook) {
+80 -11
View File
@@ -3,6 +3,7 @@ const express = require('express')
const http = require('http')
const SocketIO = require('socket.io')
const fs = require('fs-extra')
const fileUpload = require('express-fileupload')
const Auth = require('./Auth')
const Watcher = require('./Watcher')
@@ -33,8 +34,8 @@ class Server {
this.scanner = new Scanner(this.AudiobookPath, this.MetadataPath, this.db, this.emitter.bind(this))
this.streamManager = new StreamManager(this.db, this.MetadataPath)
this.rssFeeds = new RssFeeds(this.Port, this.db)
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.emitter.bind(this))
this.apiController = new ApiController(this.db, this.scanner, this.auth, this.streamManager, this.rssFeeds, this.downloadManager, this.emitter.bind(this))
this.downloadManager = new DownloadManager(this.db, this.MetadataPath, this.AudiobookPath, 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.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.scanner, this.auth, this.streamManager, this.emitter.bind(this), this.MetadataPath)
this.server = null
@@ -50,8 +51,12 @@ class Server {
get audiobooks() {
return this.db.audiobooks
}
get settings() {
return this.db.settings
get serverSettings() {
return this.db.serverSettings
}
getClientsForUser(userId) {
return Object.values(this.clients).filter(c => c.user && c.user.id === userId)
}
emitter(ev, data) {
@@ -59,8 +64,23 @@ class Server {
this.io.emit(ev, data)
}
async fileAddedUpdated({ path, fullPath }) { }
async fileRemoved({ path, fullPath }) { }
clientEmitter(userId, ev, data) {
var clients = this.getClientsForUser(userId)
if (!clients.length) {
return Logger.error(`[Server] clientEmitter - no clients found for user ${userId}`)
}
clients.forEach((client) => {
if (client.socket) {
client.socket.emit(ev, data)
}
})
}
async filesChanged(files) {
Logger.info('[Server]', files.length, 'Files Changed')
var result = await this.scanner.filesChanged(files)
Logger.info('[Server] Files changed result', result)
}
async scan() {
Logger.info('[Server] Starting Scan')
@@ -96,9 +116,7 @@ class Server {
this.auth.init()
this.watcher.initWatcher()
this.watcher.on('file_added', this.fileAddedUpdated.bind(this))
this.watcher.on('file_removed', this.fileRemoved.bind(this))
this.watcher.on('file_updated', this.fileAddedUpdated.bind(this))
this.watcher.on('files', this.filesChanged.bind(this))
}
authMiddleware(req, res, next) {
@@ -115,6 +133,7 @@ class Server {
this.server = http.createServer(app)
app.use(this.auth.cors)
app.use(fileUpload())
// Static path to generated nuxt
const distPath = Path.join(global.appRoot, '/client/dist')
@@ -138,6 +157,39 @@ class Server {
// app.use('/hls', this.hlsController.router)
app.use('/feeds', this.rssFeeds.router)
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
var files = Object.values(req.files)
var title = req.body.title
var author = req.body.author
var series = req.body.series
if (!files.length || !title || !author) {
return res.json({
error: 'Invalid post data received'
})
}
var outputDirectory = ''
if (series && series.length && series !== 'null') {
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
} else {
outputDirectory = Path.join(this.AudiobookPath, author, title)
}
await fs.ensureDir(outputDirectory)
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
for (let i = 0; i < files.length; i++) {
var file = files[i]
var path = Path.join(outputDirectory, file.name)
await file.mv(path).catch((error) => {
Logger.error('Failed to move file', path, error)
})
}
res.sendStatus(200)
})
app.post('/login', (req, res) => this.auth.login(req, res))
app.post('/logout', this.logout.bind(this))
app.get('/ping', (req, res) => {
@@ -145,7 +197,6 @@ 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)
@@ -182,13 +233,23 @@ class Server {
Logger.info('[SOCKET] Socket Connected', socket.id)
socket.on('auth', (token) => this.authenticateSocket(socket, token))
// Scanning
socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
// Streaming
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('progress_update', (payload) => this.audiobookProgressUpdate(socket.sheepClient, payload))
// Downloading
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
socket.on('test', () => {
socket.emit('test_received', socket.id)
})
@@ -213,6 +274,14 @@ class Server {
res.sendStatus(200)
}
audiobookProgressUpdate(client, progressPayload) {
if (!client || !client.user) {
Logger.error('[Server] audiobookProgressUpdate invalid socket client')
return
}
client.user.updateAudiobookProgress(progressPayload.audiobookId, progressPayload)
}
async authenticateSocket(socket, token) {
var user = await this.auth.verifyToken(token)
if (!user) {
@@ -239,7 +308,7 @@ class Server {
}
const initialPayload = {
settings: this.settings,
serverSettings: this.serverSettings.toJSON(),
isScanning: this.isScanning,
isInitialized: this.isInitialized,
audiobookPath: this.AudiobookPath,
+3 -3
View File
@@ -122,7 +122,7 @@ class StreamManager {
streamUpdate(socket, { currentTime, streamId }) {
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.id : 'No Client')
return
}
if (client.stream.id !== streamId) {
@@ -134,11 +134,11 @@ class StreamManager {
Logger.error('No User for client', client)
return
}
if (!client.user.updateAudiobookProgress) {
if (!client.user.updateAudiobookProgressFromStream) {
Logger.error('Invalid User for client', client)
return
}
client.user.updateAudiobookProgress(client.stream)
client.user.updateAudiobookProgressFromStream(client.stream)
this.db.updateEntity('user', client.user)
}
}
+53 -18
View File
@@ -1,6 +1,7 @@
var EventEmitter = require('events')
var Logger = require('./Logger')
var Watcher = require('watcher')
const Path = require('path')
const EventEmitter = require('events')
const Watcher = require('watcher')
const Logger = require('./Logger')
class FolderWatcher extends EventEmitter {
constructor(audiobookPath) {
@@ -8,6 +9,10 @@ class FolderWatcher extends EventEmitter {
this.AudiobookPath = audiobookPath
this.folderMap = {}
this.watcher = null
this.pendingFiles = []
this.pendingDelay = 4000
this.pendingTimeout = null
}
initWatcher() {
@@ -25,7 +30,8 @@ class FolderWatcher extends EventEmitter {
.on('add', (path) => {
this.onNewFile(path)
}).on('change', (path) => {
this.onFileUpdated(path)
// This is triggered from metadata changes, not what we want
// this.onFileUpdated(path)
}).on('unlink', path => {
this.onFileRemoved(path)
}).on('rename', (path, pathNext) => {
@@ -38,39 +44,68 @@ class FolderWatcher extends EventEmitter {
} catch (error) {
Logger.error('Chokidar watcher failed', error)
}
}
close() {
return this.watcher.close()
}
onNewFile(path) {
// After [pendingBatchDelay] seconds emit batch
async onNewFile(path) {
if (this.pendingFiles.includes(path)) return
Logger.debug('FolderWatcher: New File', path)
this.emit('file_added', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
var dir = Path.dirname(path)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(path)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
}
onFileRemoved(path) {
Logger.debug('[FolderWatcher] File Removed', path)
this.emit('file_removed', {
path: path.replace(this.AudiobookPath, ''),
fullPath: path
})
var dir = Path.dirname(path)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(path)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
}
onFileUpdated(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}`)
var dir = Path.dirname(pathTo)
if (dir === this.AudiobookPath) {
Logger.debug('New File added to root dir, ignoring it')
return
}
this.pendingFiles.push(pathTo)
clearTimeout(this.pendingTimeout)
this.pendingTimeout = setTimeout(() => {
this.emit('files', this.pendingFiles.map(f => f))
this.pendingFiles = []
}, this.pendingDelay)
}
}
module.exports = FolderWatcher
+13 -1
View File
@@ -20,6 +20,7 @@ class AudioFile {
this.timeBase = null
this.channels = null
this.channelLayout = null
this.chapters = []
this.tagAlbum = null
this.tagArtist = null
@@ -29,6 +30,7 @@ class AudioFile {
this.manuallyVerified = false
this.invalid = false
this.exclude = false
this.error = null
if (data) {
@@ -49,6 +51,7 @@ class AudioFile {
trackNumFromFilename: this.trackNumFromFilename,
manuallyVerified: !!this.manuallyVerified,
invalid: !!this.invalid,
exclude: !!this.exclude,
error: this.error || null,
format: this.format,
duration: this.duration,
@@ -58,6 +61,7 @@ class AudioFile {
timeBase: this.timeBase,
channels: this.channels,
channelLayout: this.channelLayout,
chapters: this.chapters,
tagAlbum: this.tagAlbum,
tagArtist: this.tagArtist,
tagGenre: this.tagGenre,
@@ -76,6 +80,7 @@ class AudioFile {
this.addedAt = data.addedAt
this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid
this.exclude = !!data.exclude
this.error = data.error || null
this.trackNumFromMeta = data.trackNumFromMeta || null
@@ -90,6 +95,7 @@ class AudioFile {
this.timeBase = data.timeBase
this.channels = data.channels
this.channelLayout = data.channelLayout
this.chapters = data.chapters
this.tagAlbum = data.tagAlbum
this.tagArtist = data.tagArtist
@@ -112,17 +118,19 @@ class AudioFile {
this.manuallyVerified = !!data.manuallyVerified
this.invalid = !!data.invalid
this.exclude = !!data.exclude
this.error = data.error || null
this.format = data.format
this.duration = data.duration
this.size = data.size
this.bitRate = data.bit_rate
this.bitRate = data.bit_rate || null
this.language = data.language
this.codec = data.codec
this.timeBase = data.time_base
this.channels = data.channels
this.channelLayout = data.channel_layout
this.chapters = data.chapters || []
this.tagAlbum = data.file_tag_album || null
this.tagArtist = data.file_tag_artist || null
@@ -131,6 +139,10 @@ class AudioFile {
this.tagTrack = data.file_tag_track || null
}
clone() {
return new AudioFile(this.toJSON())
}
syncFile(newFile) {
var hasUpdates = false
var keysToSync = ['path', 'fullPath', 'ext', 'filename']
+3 -3
View File
@@ -97,12 +97,12 @@ class AudioTrack {
this.format = probeData.format
this.duration = probeData.duration
this.size = probeData.size
this.bitRate = probeData.bit_rate
this.bitRate = probeData.bitRate
this.language = probeData.language
this.codec = probeData.codec
this.timeBase = probeData.time_base
this.timeBase = probeData.timeBase
this.channels = probeData.channels
this.channelLayout = probeData.channel_layout
this.channelLayout = probeData.channelLayout
this.tagAlbum = probeData.file_tag_album || null
this.tagArtist = probeData.file_tag_artist || null
+89 -15
View File
@@ -26,6 +26,10 @@ class Audiobook {
this.tags = []
this.book = null
this.chapters = []
// Audiobook was scanned and not found
this.isMissing = false
if (audiobook) {
this.construct(audiobook)
@@ -51,20 +55,25 @@ class Audiobook {
if (audiobook.book) {
this.book = new Book(audiobook.book)
}
if (audiobook.chapters) {
this.chapters = audiobook.chapters.map(c => ({ ...c }))
}
this.isMissing = !!audiobook.isMissing
}
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 cover() {
return this.book ? this.book.cover : ''
}
get authorLF() {
return this.book ? this.book.authorLF : null
}
@@ -122,7 +131,9 @@ class Audiobook {
book: this.bookToJSON(),
tracks: this.tracksToJSON(),
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON())
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
chapters: this.chapters || [],
isMissing: !!this.isMissing
}
}
@@ -141,7 +152,9 @@ class Audiobook {
hasBookMatch: !!this.book,
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
numTracks: this.tracks.length
numTracks: this.tracks.length,
chapters: this.chapters || [],
isMissing: !!this.isMissing
}
}
@@ -162,7 +175,9 @@ class Audiobook {
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
tags: this.tags,
book: this.bookToJSON(),
tracks: this.tracksToJSON()
tracks: this.tracksToJSON(),
chapters: this.chapters || [],
isMissing: !!this.isMissing
}
}
@@ -273,19 +288,32 @@ class Audiobook {
return hasUpdates
}
updateAudioTracks(files) {
updateAudioTracks(orderedFileData) {
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.audioFiles = orderedFileData.map((fileData) => {
var audioFile = this.audioFiles.find(af => af.ino === fileData.ino)
audioFile.manuallyVerified = true
audioFile.invalid = false
audioFile.error = null
if (fileData.exclude !== undefined) {
audioFile.exclude = !!fileData.exclude
}
if (audioFile.exclude) {
audioFile.index = -1
} else {
audioFile.index = index++
}
return audioFile
})
this.audioFiles.sort((a, b) => a.index - b.index)
this.tracks = []
this.missingParts = []
this.audioFiles.forEach((file) => {
this.addTrack(file)
if (!file.exclude) {
this.addTrack(file)
}
})
this.lastUpdate = Date.now()
}
@@ -390,5 +418,51 @@ class Audiobook {
getAudioFileByIno(ino) {
return this.audioFiles.find(af => af.ino === ino)
}
setChapters() {
// If 1 audio file without chapters, then no chapters will be set
var includedAudioFiles = this.audioFiles.filter(af => !af.exclude)
if (includedAudioFiles.length === 1) {
// 1 audio file with chapters
if (includedAudioFiles[0].chapters) {
this.chapters = includedAudioFiles[0].chapters.map(c => ({ ...c }))
}
} else {
this.chapters = []
var currChapterId = 0
var currStartTime = 0
includedAudioFiles.forEach((file) => {
// If audio file has chapters use chapters
if (file.chapters && file.chapters.length) {
file.chapters.forEach((chapter) => {
var chapterDuration = chapter.end - chapter.start
if (chapterDuration > 0) {
var title = `Chapter ${currChapterId}`
if (chapter.title) {
title += ` (${chapter.title})`
}
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + chapterDuration,
title
})
currStartTime += chapterDuration
}
})
} else if (file.duration) {
// Otherwise just use track has chapter
this.chapters.push({
id: currChapterId++,
start: currStartTime,
end: currStartTime + file.duration,
title: `Chapter ${currChapterId}`
})
currStartTime += file.duration
}
})
}
}
}
module.exports = Audiobook
+91
View File
@@ -0,0 +1,91 @@
class AudiobookProgress {
constructor(progress) {
this.audiobookId = null
this.totalDuration = null // seconds
this.progress = null // 0 to 1
this.currentTime = null // seconds
this.isRead = false
this.lastUpdate = null
this.startedAt = null
this.finishedAt = null
if (progress) {
this.construct(progress)
}
}
toJSON() {
return {
audiobookId: this.audiobookId,
totalDuration: this.totalDuration,
progress: this.progress,
currentTime: this.currentTime,
isRead: this.isRead,
lastUpdate: this.lastUpdate,
startedAt: this.startedAt,
finishedAt: this.finishedAt
}
}
construct(progress) {
this.audiobookId = progress.audiobookId
this.totalDuration = progress.totalDuration
this.progress = progress.progress
this.currentTime = progress.currentTime
this.isRead = !!progress.isRead
this.lastUpdate = progress.lastUpdate
this.startedAt = progress.startedAt
this.finishedAt = progress.finishedAt || null
}
updateFromStream(stream) {
this.audiobookId = stream.audiobookId
this.totalDuration = stream.totalDuration
this.progress = stream.clientProgress
this.currentTime = stream.clientCurrentTime
this.lastUpdate = Date.now()
if (!this.startedAt) {
this.startedAt = Date.now()
}
// If has < 10 seconds remaining mark as read
var timeRemaining = this.totalDuration - this.currentTime
if (timeRemaining < 10) {
if (!this.isRead) {
this.isRead = true
this.progress = 1
this.finishedAt = Date.now()
}
} else {
this.isRead = false
this.finishedAt = null
}
}
update(payload) {
var hasUpdates = false
for (const key in payload) {
if (payload[key] !== this[key]) {
if (key === 'isRead') {
if (!payload[key]) { // Updating to Not Read - Reset progress and current time
this.finishedAt = null
this.progress = 0
this.currentTime = 0
} else { // Updating to Read
if (!this.finishedAt) this.finishedAt = Date.now()
this.progress = 1
}
}
this[key] = payload[key]
hasUpdates = true
}
}
if (!this.startedAt) {
this.startedAt = Date.now()
}
return hasUpdates
}
}
module.exports = AudiobookProgress
+11 -1
View File
@@ -6,9 +6,11 @@ class Book {
constructor(book = null) {
this.olid = null
this.title = null
this.subtitle = null
this.author = null
this.authorFL = null
this.authorLF = null
this.narrarator = null
this.series = null
this.volumeNumber = null
this.publishYear = null
@@ -23,15 +25,19 @@ class Book {
}
get _title() { return this.title || '' }
get _subtitle() { return this.subtitle || '' }
get _narrarator() { return this.narrarator || '' }
get _author() { return this.author || '' }
get _series() { return this.series || '' }
construct(book) {
this.olid = book.olid
this.title = book.title
this.subtitle = book.subtitle || null
this.author = book.author
this.authorFL = book.authorFL || null
this.authorLF = book.authorLF || null
this.narrarator = book.narrarator || null
this.series = book.series
this.volumeNumber = book.volumeNumber || null
this.publishYear = book.publishYear
@@ -45,9 +51,11 @@ class Book {
return {
olid: this.olid,
title: this.title,
subtitle: this.subtitle,
author: this.author,
authorFL: this.authorFL,
authorLF: this.authorLF,
narrarator: this.narrarator,
series: this.series,
volumeNumber: this.volumeNumber,
publishYear: this.publishYear,
@@ -80,7 +88,9 @@ class Book {
setData(data) {
this.olid = data.olid || null
this.title = data.title || null
this.subtitle = data.subtitle || null
this.author = data.author || null
this.narrarator = data.narrarator || null
this.series = data.series || null
this.volumeNumber = data.volumeNumber || null
this.publishYear = data.publishYear || null
@@ -151,7 +161,7 @@ class Book {
}
isSearchMatch(search) {
return this._title.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
return this._title.toLowerCase().includes(search) || this._subtitle.toLowerCase().includes(search) || this._author.toLowerCase().includes(search) || this._series.toLowerCase().includes(search)
}
}
module.exports = Book
+35 -3
View File
@@ -1,5 +1,5 @@
const DEFAULT_EXPIRATION = 1000 * 60 * 10 // 10 minutes
const DEFAULT_EXPIRATION = 1000 * 60 * 60 // 60 minutes
const DEFAULT_TIMEOUT = 1000 * 60 * 15 // 15 minutes
class Download {
constructor(download) {
this.id = null
@@ -16,18 +16,31 @@ class Download {
this.userId = null
this.socket = null // Socket to notify when complete
this.isReady = false
this.isTimedOut = false
this.startedAt = null
this.finishedAt = null
this.expiresAt = null
this.expirationTimeMs = 0
this.timeoutTimeMs = 0
this.timeoutTimer = null
this.expirationTimer = null
if (download) {
this.construct(download)
}
}
get includeMetadata() {
return !!this.options.includeMetadata
}
get includeCover() {
return !!this.options.includeCover
}
get mimeType() {
if (this.ext === '.mp3' || this.ext === '.m4b' || this.ext === '.m4a') {
return 'audio/mpeg'
@@ -80,6 +93,8 @@ class Download {
this.finishedAt = download.finishedAt || null
this.expirationTimeMs = download.expirationTimeMs || DEFAULT_EXPIRATION
this.timeoutTimeMs = download.timeoutTimeMs || DEFAULT_TIMEOUT
this.expiresAt = download.expiresAt || null
}
@@ -97,11 +112,28 @@ class Download {
}
setExpirationTimer(callback) {
setTimeout(() => {
this.expirationTimer = setTimeout(() => {
if (callback) {
callback(this)
}
}, this.expirationTimeMs)
}
setTimeoutTimer(callback) {
this.timeoutTimer = setTimeout(() => {
if (callback) {
this.isTimedOut = true
callback(this)
}
}, this.timeoutTimeMs)
}
clearTimeoutTimer() {
clearTimeout(this.timeoutTimer)
}
clearExpirationTimer() {
clearTimeout(this.expirationTimer)
}
}
module.exports = Download
+39
View File
@@ -0,0 +1,39 @@
class ServerSettings {
constructor(settings) {
this.id = 'server-settings'
this.autoTagNew = false
this.newTagExpireDays = 15
this.scannerParseSubtitle = false
if (settings) {
this.construct(settings)
}
}
construct(settings) {
this.autoTagNew = settings.autoTagNew
this.newTagExpireDays = settings.newTagExpireDays
this.scannerParseSubtitle = settings.scannerParseSubtitle
}
toJSON() {
return {
id: this.id,
autoTagNew: this.autoTagNew,
newTagExpireDays: this.newTagExpireDays,
scannerParseSubtitle: this.scannerParseSubtitle
}
}
update(payload) {
var hasUpdates = false
for (const key in payload) {
if (this[key] !== payload[key]) {
this[key] = payload[key]
hasUpdates = true
}
}
return hasUpdates
}
}
module.exports = ServerSettings
+21 -6
View File
@@ -43,6 +43,10 @@ class Stream extends EventEmitter {
return this.audiobook.id
}
get audiobookTitle() {
return this.audiobook ? this.audiobook.title : null
}
get totalDuration() {
return this.audiobook.totalDuration
}
@@ -191,7 +195,7 @@ class Stream extends EventEmitter {
this.socket.emit('stream_progress', {
stream: this.id,
percentCreated: perc,
percent: perc,
chunks,
numSegments: this.numSegments
})
@@ -201,15 +205,20 @@ class Stream extends EventEmitter {
}
startLoop() {
this.socket.emit('stream_progress', { chunks: [], numSegments: 0 })
this.loop = setInterval(() => {
// Logger.info(`[Stream] ${this.audiobookTitle} (${this.id}) Start Loop`)
this.socket.emit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })
clearInterval(this.loop)
var intervalId = setInterval(() => {
if (!this.isTranscodeComplete) {
this.checkFiles()
} else {
Logger.info(`[Stream] ${this.audiobookTitle} sending stream_ready`)
this.socket.emit('stream_ready')
clearTimeout(this.loop)
clearInterval(intervalId)
}
}, 2000)
this.loop = intervalId
}
async start() {
@@ -230,8 +239,9 @@ class Stream extends EventEmitter {
this.ffmpeg.inputOption('-noaccurate_seek')
}
const logLevel = process.env.NODE_ENV === 'production' ? 'error' : 'warning'
this.ffmpeg.addOption([
'-loglevel warning',
`-loglevel ${logLevel}`,
'-map 0:a',
'-c:a copy'
])
@@ -254,13 +264,16 @@ class Stream extends EventEmitter {
this.ffmpeg.on('start', (command) => {
Logger.info('[INFO] FFMPEG transcoding started with command: ' + command)
Logger.info('')
if (this.isResetting) {
setTimeout(() => {
Logger.info('[STREAM] Clearing isResetting')
this.isResetting = false
this.startLoop()
}, 500)
} else {
this.startLoop()
}
this.startLoop()
})
this.ffmpeg.on('stderr', (stdErrline) => {
@@ -275,6 +288,7 @@ class Stream extends EventEmitter {
} else {
Logger.error('Ffmpeg Err', err.message)
}
clearInterval(this.loop)
})
this.ffmpeg.on('end', (stdout, stderr) => {
@@ -287,6 +301,7 @@ class Stream extends EventEmitter {
}
this.isTranscodeComplete = true
this.ffmpeg = null
clearInterval(this.loop)
})
this.ffmpeg.run()
+104 -15
View File
@@ -1,3 +1,5 @@
const AudiobookProgress = require('./AudiobookProgress')
class User {
constructor(user) {
this.id = null
@@ -9,13 +11,28 @@ class User {
this.isActive = true
this.createdAt = null
this.audiobooks = null
this.settings = {}
this.permissions = {}
if (user) {
this.construct(user)
}
}
get isRoot() {
return this.type === 'root'
}
get canDelete() {
return !!this.permissions.delete && this.isActive
}
get canUpdate() {
return !!this.permissions.update && this.isActive
}
get canDownload() {
return !!this.permissions.download && this.isActive
}
getDefaultUserSettings() {
return {
orderBy: 'book.title',
@@ -26,6 +43,25 @@ class User {
}
}
getDefaultUserPermissions() {
return {
download: true,
update: true,
delete: this.id === 'root'
}
}
audiobooksToJSON() {
if (!this.audiobooks) return null
var _map = {}
for (const key in this.audiobooks) {
if (this.audiobooks[key]) {
_map[key] = this.audiobooks[key].toJSON()
}
}
return _map
}
toJSON() {
return {
id: this.id,
@@ -34,10 +70,11 @@ class User {
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
settings: this.settings,
permissions: this.permissions
}
}
@@ -48,10 +85,11 @@ class User {
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
settings: this.settings,
permissions: this.permissions
}
}
@@ -62,24 +100,61 @@ class User {
this.type = user.type
this.stream = user.stream || null
this.token = user.token
this.audiobooks = user.audiobooks || null
if (user.audiobooks) {
this.audiobooks = {}
for (const key in user.audiobooks) {
if (user.audiobooks[key]) {
this.audiobooks[key] = new AudiobookProgress(user.audiobooks[key])
}
}
}
this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive
this.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings()
this.permissions = user.permissions || this.getDefaultUserPermissions()
}
updateAudiobookProgress(stream) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[stream.audiobookId]) {
this.audiobooks[stream.audiobookId] = {
audiobookId: stream.audiobookId,
totalDuration: stream.totalDuration,
startedAt: Date.now()
update(payload) {
var hasUpdates = false
// Update the following keys:
const keysToCheck = ['pash', 'type', 'username', 'isActive']
keysToCheck.forEach((key) => {
if (payload[key] !== undefined) {
if (key === 'isActive' || payload[key]) { // pash, type, username must evaluate to true (cannot be null or empty)
if (payload[key] !== this[key]) {
hasUpdates = true
this[key] = payload[key]
}
}
}
})
// And update permissions
if (payload.permissions) {
for (const key in payload.permissions) {
if (payload.permissions[key] !== this.permissions[key]) {
hasUpdates = true
this.permissions[key] = payload.permissions[key]
}
}
}
this.audiobooks[stream.audiobookId].lastUpdate = Date.now()
this.audiobooks[stream.audiobookId].progress = stream.clientProgress
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
return hasUpdates
}
updateAudiobookProgressFromStream(stream) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[stream.audiobookId]) {
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
}
this.audiobooks[stream.audiobookId].updateFromStream(stream)
}
updateAudiobookProgress(audiobookId, updatePayload) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[audiobookId]) {
this.audiobooks[audiobookId] = new AudiobookProgress()
this.audiobooks[audiobookId].audiobookId = audiobookId
}
return this.audiobooks[audiobookId].update(updatePayload)
}
// Returns Boolean If update was made
@@ -109,6 +184,20 @@ class User {
}
resetAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false
}
return this.updateAudiobookProgress(audiobookId, {
progress: 0,
currentTime: 0,
isRead: false,
lastUpdate: Date.now(),
startedAt: null,
finishedAt: null
})
}
deleteAudiobookProgress(audiobookId) {
if (!this.audiobooks || !this.audiobooks[audiobookId]) {
return false
}
+26 -7
View File
@@ -33,7 +33,8 @@ async function scan(path) {
language: audioStream.language,
channel_layout: audioStream.channel_layout,
channels: audioStream.channels,
sample_rate: audioStream.sample_rate
sample_rate: audioStream.sample_rate,
chapters: probeData.chapters || []
}
for (const key in probeData) {
@@ -63,11 +64,18 @@ function isNumber(val) {
}
function getTrackNumberFromMeta(scanData) {
return !isNaN(scanData.trackNumber) && scanData.trackNumber !== null ? Number(scanData.trackNumber) : null
return !isNaN(scanData.trackNumber) && scanData.trackNumber !== null ? Math.trunc(Number(scanData.trackNumber)) : null
}
function getTrackNumberFromFilename(filename) {
function getTrackNumberFromFilename(title, author, series, publishYear, filename) {
var partbasename = Path.basename(filename, Path.extname(filename))
// Remove title, author, series, and publishYear from filename if there
if (title) partbasename = partbasename.replace(title, '')
if (author) partbasename = partbasename.replace(author, '')
if (series) partbasename = partbasename.replace(series, '')
if (publishYear) partbasename = partbasename.replace(publishYear)
var numbersinpath = partbasename.match(/\d+/g)
if (!numbersinpath) return null
@@ -81,9 +89,10 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
return
}
var tracks = []
var numDuplicateTracks = 0
var numInvalidTracks = 0
for (let i = 0; i < newAudioFiles.length; i++) {
var audioFile = newAudioFiles[i]
var scanData = await scan(audioFile.fullPath)
if (!scanData || scanData.error) {
Logger.error('[AudioFileScanner] Scan failed for', audioFile.path)
@@ -92,7 +101,8 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
}
var trackNumFromMeta = getTrackNumberFromMeta(scanData)
var trackNumFromFilename = getTrackNumberFromFilename(audioFile.filename)
var book = audiobook.book || {}
var trackNumFromFilename = getTrackNumberFromFilename(book.title, book.author, book.series, book.publishYear, audioFile.filename)
var audioFileObj = {
ino: audioFile.ino,
@@ -110,17 +120,19 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
if (newAudioFiles.length > 1) {
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
if (trackNumber === null) {
Logger.error('[AudioFileScanner] Invalid track number for', audioFile.filename)
Logger.debug('[AudioFileScanner] Invalid track number for', audioFile.filename)
audioFile.invalid = true
audioFile.error = 'Failed to get track number'
numInvalidTracks++
continue;
}
}
if (tracks.find(t => t.index === trackNumber)) {
Logger.error('[AudioFileScanner] Duplicate track number for', audioFile.filename)
Logger.debug('[AudioFileScanner] Duplicate track number for', audioFile.filename)
audioFile.invalid = true
audioFile.error = 'Duplicate track number'
numDuplicateTracks++
continue;
}
@@ -133,6 +145,13 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
return
}
if (numDuplicateTracks > 0) {
Logger.warn(`[AudioFileScanner] ${numDuplicateTracks} Duplicate tracks for "${audiobook.title}"`)
}
if (numInvalidTracks > 0) {
Logger.error(`[AudioFileScanner] ${numDuplicateTracks} Invalid tracks for "${audiobook.title}"`)
}
tracks.sort((a, b) => a.index - b.index)
audiobook.audioFiles.sort((a, b) => {
var aNum = isNumber(a.trackNumFromMeta) ? a.trackNumFromMeta : isNumber(a.trackNumFromFilename) ? a.trackNumFromFilename : 0
+7
View File
@@ -0,0 +1,7 @@
module.exports.ScanResult = {
NOTHING: 0,
ADDED: 1,
UPDATED: 2,
REMOVED: 3,
UPTODATE: 4
}
+6 -3
View File
@@ -13,10 +13,13 @@ 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)
workerData.inputs.forEach((inputData) => {
ffmpegCommand.input(inputData.input)
if (inputData.options) ffmpegCommand.inputOption(inputData.options)
})
if (workerData.options) ffmpegCommand.addOption(workerData.options)
if (workerData.outputOptions && workerData.outputOptions.length) ffmpegCommand.addOutputOption(workerData.outputOptions)
ffmpegCommand.output(workerData.output)
var isKilled = false
+31 -1
View File
@@ -1,4 +1,5 @@
const fs = require('fs-extra')
const package = require('../../package.json')
function escapeSingleQuotes(path) {
// return path.replace(/'/g, '\'\\\'\'')
@@ -34,4 +35,33 @@ async function writeConcatFile(tracks, outputPath, startTime = 0) {
return firstTrackStartTime
}
module.exports.writeConcatFile = writeConcatFile
module.exports.writeConcatFile = writeConcatFile
async function writeMetadataFile(audiobook, outputPath) {
var inputstrs = [
';FFMETADATA1',
`title=${audiobook.title}`,
`artist=${audiobook.author}`,
`date=${audiobook.book.publishYear || ''}`,
`comment=AudioBookshelf v${package.version}`,
'genre=Audiobook'
]
if (audiobook.chapters) {
audiobook.chapters.forEach((chap) => {
const chapterstrs = [
'[CHAPTER]',
'TIMEBASE=1/1000',
`START=${Math.round(chap.start * 1000)}`,
`END=${Math.round(chap.end * 1000)}`,
`title=${chap.title}`
]
inputstrs = inputstrs.concat(chapterstrs)
})
}
await fs.writeFile(outputPath, inputstrs.join('\n'))
return inputstrs
}
module.exports.writeMetadataFile = writeMetadataFile
+12 -9
View File
@@ -32,17 +32,20 @@ module.exports.levenshteinDistance = levenshteinDistance
const cleanString = (str) => {
if (!str) return ''
// Now supporting all utf-8 characters, can remove this method in future
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
// 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
// var cleaned = ''
// for (let i = 0; i < str.length; i++) {
// cleaned += cleanChar(str[i])
// }
return cleaned.trim()
}
module.exports.cleanString = cleanString
@@ -56,7 +59,7 @@ module.exports.comparePaths = (path1, 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)
Logger.error('[Utils] Failed to get ino for path', path, err)
return null
})
}
+18 -2
View File
@@ -110,9 +110,23 @@ function parseMediaStreamInfo(stream, all_streams, total_bit_rate) {
return info
}
function parseChapters(chapters) {
if (!chapters) return []
return chapters.map(chap => {
var title = chap['TAG:title'] || chap.title || ''
var timebase = chap.time_base && chap.time_base.includes('/') ? Number(chap.time_base.split('/')[1]) : 1
return {
id: chap.id,
start: !isNaN(chap.start_time) ? chap.start_time : (chap.start / timebase),
end: chap.end_time || (chap.end / timebase),
title
}
})
}
function parseProbeData(data) {
try {
var { format, streams } = data
var { format, streams, chapters } = data
var { format_long_name, duration, size, bit_rate } = format
var sizeBytes = !isNaN(size) ? Number(size) : null
@@ -146,6 +160,8 @@ function parseProbeData(data) {
}
}
cleanedData.chapters = parseChapters(chapters)
return cleanedData
} catch (error) {
console.error('Parse failed', error)
@@ -155,7 +171,7 @@ function parseProbeData(data) {
function probe(filepath) {
return new Promise((resolve) => {
Ffmpeg.ffprobe(filepath, (err, raw) => {
Ffmpeg.ffprobe(filepath, ['-show_chapters'], (err, raw) => {
if (err) {
console.error(err)
resolve(null)
+190 -52
View File
@@ -1,7 +1,6 @@
const Path = require('path')
const dir = require('node-dir')
const Logger = require('../Logger')
const { cleanString } = require('./index')
const AUDIO_FORMATS = ['m4b', 'mp3', 'm4a']
const INFO_FORMATS = ['nfo']
@@ -12,7 +11,7 @@ function getPaths(path) {
return new Promise((resolve) => {
dir.paths(path, function (err, res) {
if (err) {
console.error(err)
Logger.error(err)
resolve(false)
}
resolve(res)
@@ -20,6 +19,90 @@ function getPaths(path) {
})
}
function isAudioFile(path) {
if (!path) return false
var ext = Path.extname(path)
if (!ext) return false
return AUDIO_FORMATS.includes(ext.slice(1).toLowerCase())
}
function groupFilesIntoAudiobookPaths(paths) {
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
// Step 2: Sort by least number of directories
pathsFiltered.sort((a, b) => {
var pathsA = Path.dirname(a).split(Path.sep).length
var pathsB = Path.dirname(b).split(Path.sep).length
return pathsA - pathsB
})
// Step 2.5: Seperate audio files and other files
var audioFilePaths = []
var otherFilePaths = []
pathsFiltered.forEach(path => {
if (isAudioFile(path)) audioFilePaths.push(path)
else otherFilePaths.push(path)
})
// Step 3: Group audio files in audiobooks
var audiobookGroup = {}
audioFilePaths.forEach((path) => {
var dirparts = Path.dirname(path).split(Path.sep)
var numparts = dirparts.length
var _path = ''
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
var dirpart = dirparts.shift()
_path = Path.join(_path, dirpart)
if (audiobookGroup[_path]) { // Directory already has files, add file
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
audiobookGroup[_path].push(relpath)
return
} else if (!dirparts.length) { // This is the last directory, create group
audiobookGroup[_path] = [Path.basename(path)]
return
}
}
})
// Step 4: Add other files into audiobook groups
otherFilePaths.forEach((path) => {
var dirparts = Path.dirname(path).split(Path.sep)
var numparts = dirparts.length
var _path = ''
// Iterate over directories in path
for (let i = 0; i < numparts; i++) {
var dirpart = dirparts.shift()
_path = Path.join(_path, dirpart)
if (audiobookGroup[_path]) { // Directory is audiobook group
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
audiobookGroup[_path].push(relpath)
return
}
}
})
return audiobookGroup
}
module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths
function cleanFileObjects(basepath, abrelpath, files) {
return files.map((file) => {
var ext = Path.extname(file)
return {
filetype: getFileType(ext),
filename: Path.basename(file),
path: Path.join(abrelpath, file), // /AUDIOBOOK/PATH/filename.mp3
fullPath: Path.join(basepath, file), // /audiobooks/AUDIOBOOK/PATH/filename.mp3
ext: ext
}
})
}
function getFileType(ext) {
var ext_cleaned = ext.toLowerCase()
if (ext_cleaned.startsWith('.')) ext_cleaned = ext_cleaned.slice(1)
@@ -30,64 +113,119 @@ function getFileType(ext) {
return 'unknown'
}
async function getAllAudiobookFiles(abRootPath) {
var paths = await getPaths(abRootPath)
var audiobooks = {}
// Primary scan: abRootPath is /audiobooks
async function scanRootDir(abRootPath, serverSettings = {}) {
var parseSubtitle = !!serverSettings.scannerParseSubtitle
paths.files.forEach((filepath) => {
var pathdata = await getPaths(abRootPath)
var filepaths = pathdata.files.map(filepath => {
return Path.normalize(filepath).replace(abRootPath, '')
})
var audiobookGrouping = groupFilesIntoAudiobookPaths(filepaths)
if (!Object.keys(audiobookGrouping).length) {
Logger.error('Root path has no audiobooks')
return []
}
var audiobooks = []
for (const audiobookPath in audiobookGrouping) {
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookPath, parseSubtitle)
var fileObjs = cleanFileObjects(audiobookData.fullPath, audiobookPath, audiobookGrouping[audiobookPath])
audiobooks.push({
...audiobookData,
audioFiles: fileObjs.filter(f => f.filetype === 'audio'),
otherFiles: fileObjs.filter(f => f.filetype !== 'audio')
})
}
return audiobooks
}
module.exports.scanRootDir = scanRootDir
// Input relative filepath, output all details that can be parsed
function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
var splitDir = dir.split(Path.sep)
// Audio files will always be in the directory named for the title
var title = splitDir.pop()
var series = null
var author = null
// If there are at least 2 more directories, next furthest will be the series
if (splitDir.length > 1) series = splitDir.pop()
if (splitDir.length > 0) author = splitDir.pop()
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
var publishYear = null
// If Title is of format 1999 - Title, then use 1999 as publish year
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
if (publishYearMatch && publishYearMatch.length > 2) {
if (!isNaN(publishYearMatch[1])) {
publishYear = publishYearMatch[1]
title = publishYearMatch[2]
}
}
// Subtitle can be parsed from the title if user enabled
var subtitle = null
if (parseSubtitle && title.includes(' - ')) {
var splitOnSubtitle = title.split(' - ')
title = splitOnSubtitle.shift()
subtitle = splitOnSubtitle.join(' - ')
}
return {
author,
title,
subtitle,
series,
publishYear,
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
}
}
async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) {
var parseSubtitle = !!serverSettings.scannerParseSubtitle
var paths = await getPaths(audiobookPath)
var filepaths = paths.files
// Sort by least number of directories
filepaths.sort((a, b) => {
var pathsA = Path.dirname(a).split(Path.sep).length
var pathsB = Path.dirname(b).split(Path.sep).length
return pathsA - pathsB
})
var audiobookDir = Path.normalize(audiobookPath).replace(abRootPath, '').slice(1)
var audiobookData = getAudiobookDataFromDir(abRootPath, audiobookDir, parseSubtitle)
var audiobook = {
...audiobookData,
audioFiles: [],
otherFiles: []
}
filepaths.forEach((filepath) => {
var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1)
var pathformat = Path.parse(relpath)
var path = pathformat.dir
if (!path) {
Logger.error('Ignoring file in root dir', filepath)
return
}
// 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()
var publishYear = null
// If Title is of format 1999 - Title, then use 1999 as publish year
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
if (publishYearMatch && publishYearMatch.length > 2) {
if (!isNaN(publishYearMatch[1])) {
publishYear = publishYearMatch[1]
title = publishYearMatch[2]
}
}
if (!audiobooks[path]) {
audiobooks[path] = {
author: author,
title: title,
series: cleanString(series),
publishYear: publishYear,
path: path,
fullPath: Path.join(abRootPath, path),
audioFiles: [],
otherFiles: []
}
}
var extname = Path.extname(filepath)
var basename = Path.basename(filepath)
var fileObj = {
filetype: getFileType(pathformat.ext),
filename: pathformat.base,
filetype: getFileType(extname),
filename: basename,
path: relpath,
fullPath: filepath,
ext: pathformat.ext
ext: extname
}
if (fileObj.filetype === 'audio') {
audiobooks[path].audioFiles.push(fileObj)
audiobook.audioFiles.push(fileObj)
} else {
audiobooks[path].otherFiles.push(fileObj)
audiobook.otherFiles.push(fileObj)
}
})
return Object.values(audiobooks)
return audiobook
}
module.exports.getAllAudiobookFiles = getAllAudiobookFiles
module.exports.getAudiobookFileData = getAudiobookFileData