mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-08 03:32:43 +02:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d97422011 | |||
| 2f2a64b89e | |||
| b2e129eec7 | |||
| cb79e48685 | |||
| e735ef7869 | |||
| 8f1152762a | |||
| 587adb3773 | |||
| db01db3a2b | |||
| 0851a1e71e | |||
| 0addfc8269 | |||
| b2ab5730f5 |
@@ -315,7 +315,6 @@ export default {
|
|||||||
this.bufferTrackWidth = bufferlen
|
this.bufferTrackWidth = bufferlen
|
||||||
},
|
},
|
||||||
timeupdate() {
|
timeupdate() {
|
||||||
// console.log('Time update', this.audioEl.currentTime)
|
|
||||||
if (!this.$refs.playedTrack) {
|
if (!this.$refs.playedTrack) {
|
||||||
console.error('Invalid no played track ref')
|
console.error('Invalid no played track ref')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
<span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span>
|
<span class="pl-2">Update is available! Check release notes for v{{ latestVersion }}</span>
|
||||||
</a> -->
|
</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">
|
<nuxt-link v-if="userCanUpload" to="/upload" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center">
|
||||||
<span class="material-icons">upload</span>
|
<span class="material-icons">upload</span>
|
||||||
</nuxt-link>
|
</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">
|
<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 ml-4">
|
||||||
<span class="material-icons">settings</span>
|
<span class="material-icons">settings</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
@@ -39,10 +39,16 @@
|
|||||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<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">
|
<template v-if="userCanUpdate">
|
||||||
<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-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>
|
</template>
|
||||||
<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>
|
<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>
|
<span class="material-icons text-4xl px-4 hover:text-gray-100 cursor-pointer" :class="processingBatchDelete ? 'text-gray-400' : ''" @click="cancelSelectionMode">close</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,6 +84,9 @@ export default {
|
|||||||
isAllSelected() {
|
isAllSelected() {
|
||||||
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
return this.audiobooksShowing.length === this.selectedAudiobooks.length
|
||||||
},
|
},
|
||||||
|
userAudiobooks() {
|
||||||
|
return this.$store.state.user.user.audiobooks || {}
|
||||||
|
},
|
||||||
audiobooksShowing() {
|
audiobooksShowing() {
|
||||||
return this.$store.getters['audiobooks/getFiltered']()
|
return this.$store.getters['audiobooks/getFiltered']()
|
||||||
},
|
},
|
||||||
@@ -86,6 +95,19 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanDelete() {
|
userCanDelete() {
|
||||||
return this.$store.getters['user/getUserCanDelete']
|
return this.$store.getters['user/getUserCanDelete']
|
||||||
|
},
|
||||||
|
userCanUpload() {
|
||||||
|
return this.$store.getters['user/getUserCanUpload']
|
||||||
|
},
|
||||||
|
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: {
|
methods: {
|
||||||
@@ -108,8 +130,32 @@ export default {
|
|||||||
this.$store.commit('setSelectedAudiobooks', audiobookIds)
|
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() {
|
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.processingBatchDelete = true
|
||||||
this.$store.commit('setProcessingBatch', true)
|
this.$store.commit('setProcessingBatch', true)
|
||||||
this.$axios
|
this.$axios
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<cards-book-cover :audiobook="audiobook" :author-override="authorFormat" :width="width" />
|
<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="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">
|
<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>
|
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="userCanUpdate || userCanDelete" 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">
|
<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>
|
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,7 +132,10 @@ export default {
|
|||||||
return this.userProgress ? !!this.userProgress.isRead : false
|
return this.userProgress ? !!this.userProgress.isRead : false
|
||||||
},
|
},
|
||||||
showError() {
|
showError() {
|
||||||
return this.hasMissingParts || this.hasInvalidParts
|
return this.hasMissingParts || this.hasInvalidParts || this.isMissing
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.audiobook.isMissing
|
||||||
},
|
},
|
||||||
hasMissingParts() {
|
hasMissingParts() {
|
||||||
return this.audiobook.hasMissingParts
|
return this.audiobook.hasMissingParts
|
||||||
@@ -141,6 +144,7 @@ export default {
|
|||||||
return this.audiobook.hasInvalidParts
|
return this.audiobook.hasInvalidParts
|
||||||
},
|
},
|
||||||
errorText() {
|
errorText() {
|
||||||
|
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||||
var txt = ''
|
var txt = ''
|
||||||
if (this.hasMissingParts) {
|
if (this.hasMissingParts) {
|
||||||
txt = `${this.hasMissingParts} missing parts.`
|
txt = `${this.hasMissingParts} missing parts.`
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<template v-for="item in items">
|
<template v-for="item in items">
|
||||||
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item)">
|
<li :key="item.id" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400 py-1" role="option" @click="clickedOption(item)">
|
||||||
<template v-if="item.type === 'audiobook'">
|
<template v-if="item.type === 'audiobook'">
|
||||||
<cards-audiobook-search-card :audiobook="item.data" />
|
<cards-audiobook-search-card :audiobook="item.data" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -27,9 +27,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 mt-4">
|
<div v-if="!isEditingRoot && newUser.permissions" class="w-full border-t border-b border-black-200 py-2 px-3 mt-4">
|
||||||
<p class="text-lg mb-2">Permissions</p>
|
<p class="text-lg mb-2 font-semibold">Permissions</p>
|
||||||
<div class="flex items-center my-2 max-w-lg">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Download</p>
|
<p>Can Download</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-lg">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Update</p>
|
<p>Can Update</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center my-2 max-w-lg">
|
<div class="flex items-center my-2 max-w-md">
|
||||||
<div class="w-1/2">
|
<div class="w-1/2">
|
||||||
<p>Can Delete</p>
|
<p>Can Delete</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +55,15 @@
|
|||||||
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
<ui-toggle-switch v-model="newUser.permissions.delete" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center my-2 max-w-md">
|
||||||
|
<div class="w-1/2">
|
||||||
|
<p>Can Upload</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2">
|
||||||
|
<ui-toggle-switch v-model="newUser.permissions.upload" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex pt-4">
|
<div class="flex pt-4">
|
||||||
@@ -179,7 +188,8 @@ export default {
|
|||||||
this.newUser.permissions = {
|
this.newUser.permissions = {
|
||||||
download: type !== 'guest',
|
download: type !== 'guest',
|
||||||
update: type === 'admin',
|
update: type === 'admin',
|
||||||
delete: type === 'admin'
|
delete: type === 'admin',
|
||||||
|
upload: type === 'admin'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
init() {
|
init() {
|
||||||
@@ -201,7 +211,8 @@ export default {
|
|||||||
permissions: {
|
permissions: {
|
||||||
download: true,
|
download: true,
|
||||||
update: false,
|
update: false,
|
||||||
delete: false
|
delete: false,
|
||||||
|
upload: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export default {
|
|||||||
availableTabs() {
|
availableTabs() {
|
||||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||||
return this.tabs.filter((tab) => {
|
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.userCanDownload) return true
|
||||||
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
if (tab.id !== 'download' && tab.id !== 'tracks' && this.userCanUpdate) return true
|
||||||
return false
|
return false
|
||||||
@@ -122,6 +123,9 @@ export default {
|
|||||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||||
return _tab ? _tab.component : ''
|
return _tab ? _tab.component : ''
|
||||||
},
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.selectedAudiobook.isMissing
|
||||||
|
},
|
||||||
selectedAudiobook() {
|
selectedAudiobook() {
|
||||||
return this.$store.state.selectedAudiobook || {}
|
return this.$store.state.selectedAudiobook || {}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<th class="text-left">Filename</th>
|
<th class="text-left">Filename</th>
|
||||||
<th class="text-left">Size</th>
|
<th class="text-left">Size</th>
|
||||||
<th class="text-left">Duration</th>
|
<th class="text-left">Duration</th>
|
||||||
<th v-if="userCanDownload" class="text-center">Download</th>
|
<th v-if="showDownload" class="text-center">Download</th>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="track in tracks">
|
<template v-for="track in tracks">
|
||||||
<tr :key="track.index">
|
<tr :key="track.index">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<td class="font-mono">
|
<td class="font-mono">
|
||||||
{{ $secondsToTimestamp(track.duration) }}
|
{{ $secondsToTimestamp(track.duration) }}
|
||||||
</td>
|
</td>
|
||||||
<td v-if="userCanDownload" class="font-mono text-center">
|
<td v-if="showDownload" class="font-mono text-center">
|
||||||
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -64,6 +64,12 @@ export default {
|
|||||||
},
|
},
|
||||||
userCanDownload() {
|
userCanDownload() {
|
||||||
return this.$store.getters['user/getUserCanDownload']
|
return this.$store.getters['user/getUserCanDownload']
|
||||||
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.audiobook.isMissing
|
||||||
|
},
|
||||||
|
showDownload() {
|
||||||
|
return this.userCanDownload && !this.isMissing
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<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>
|
<span class="material-icons icon-text">{{ icon }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
@@ -8,12 +8,22 @@
|
|||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
icon: String,
|
icon: String,
|
||||||
disabled: Boolean
|
disabled: Boolean,
|
||||||
|
bgColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'primary'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
className() {
|
||||||
|
var classes = []
|
||||||
|
classes.push(`bg-${this.bgColor}`)
|
||||||
|
return classes.join(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickBtn(e) {
|
clickBtn(e) {
|
||||||
if (this.disabled) {
|
if (this.disabled) {
|
||||||
@@ -29,6 +39,9 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
button.icon-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
button.icon-btn::before {
|
button.icon-btn::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default {
|
|||||||
}
|
}
|
||||||
this.isFocused = false
|
this.isFocused = false
|
||||||
if (this.input !== this.textInput) {
|
if (this.input !== this.textInput) {
|
||||||
var val = this.$cleanString(this.textInput) || null
|
var val = this.textInput ? this.textInput.trim() : null
|
||||||
this.input = val
|
this.input = val
|
||||||
if (val && !this.items.includes(val)) {
|
if (val && !this.items.includes(val)) {
|
||||||
this.$emit('newItem', val)
|
this.$emit('newItem', val)
|
||||||
@@ -105,7 +105,7 @@ export default {
|
|||||||
}, 50)
|
}, 50)
|
||||||
},
|
},
|
||||||
submitForm() {
|
submitForm() {
|
||||||
var val = this.$cleanString(this.textInput) || null
|
var val = this.textInput ? this.textInput.trim() : null
|
||||||
this.input = val
|
this.input = val
|
||||||
if (val && !this.items.includes(val)) {
|
if (val && !this.items.includes(val)) {
|
||||||
this.$emit('newItem', val)
|
this.$emit('newItem', val)
|
||||||
@@ -116,7 +116,7 @@ export default {
|
|||||||
var newValue = this.input === item ? null : item
|
var newValue = this.input === item ? null : item
|
||||||
this.textInput = null
|
this.textInput = null
|
||||||
this.currentSearch = 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()
|
if (this.$refs.input) this.$refs.input.blur()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,32 +7,6 @@
|
|||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<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" />
|
<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>
|
</svg>
|
||||||
<!-- <svg v-if="!isRead" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 482.204 482.204" xml:space="preserve" fill="currentColor">
|
|
||||||
<path
|
|
||||||
d="M83.127,344.477c54.602,1.063,101.919,9.228,136.837,23.613c0.596,0.244,1.227,0.366,1.852,0.366
|
|
||||||
c0.95,0,1.895-0.279,2.706-0.822c1.349-0.902,2.158-2.418,2.158-4.041l0.019-261.017c0-1.992-1.215-3.783-3.066-4.519
|
|
||||||
L85.019,42.899c-1.496-0.596-3.193-0.411-4.527,0.494c-1.334,0.906-2.133,2.413-2.133,4.025v292.197
|
|
||||||
C78.359,342.264,80.479,344.425,83.127,344.477z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M480.244,89.256c-1.231-0.917-2.824-1.198-4.297-0.759l-49.025,14.657
|
|
||||||
c-2.06,0.616-3.471,2.51-3.471,4.659v252.151c0,0,0.218,3.978-3.97,3.978c-4.796,0-7.946,0-7.946,0
|
|
||||||
c-39.549,0-113.045,4.105-160.93,31.6l-9.504,5.442l-9.503-5.442c-47.886-27.494-121.381-31.6-160.93-31.6c0,0-8.099,0-10.142,0
|
|
||||||
c-1.891,0-1.775-2.272-1.775-2.271V107.813c0-2.149-1.411-4.043-3.47-4.659L6.256,88.497c-1.473-0.439-3.066-0.158-4.298,0.759
|
|
||||||
S0,91.619,0,93.155v305.069c0,1.372,0.581,2.681,1.597,3.604c1.017,0.921,2.375,1.372,3.741,1.236
|
|
||||||
c14.571-1.429,37.351-3.131,63.124-3.131c56.606,0,102.097,8.266,131.576,23.913c4.331,2.272,29.441,15.803,41.065,15.803
|
|
||||||
c11.624,0,36.733-13.53,41.063-15.803c29.48-15.647,74.971-23.913,131.577-23.913c25.771,0,48.553,1.702,63.123,3.131
|
|
||||||
c1.367,0.136,2.725-0.315,3.742-1.236c1.016-0.923,1.596-2.231,1.596-3.604V93.155C482.203,91.619,481.476,90.173,480.244,89.256z
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
d="M257.679,367.634c0.812,0.543,1.757,0.822,2.706,0.822c0.626,0,1.256-0.122,1.853-0.366
|
|
||||||
c34.917-14.386,82.235-22.551,136.837-23.613c2.648-0.052,4.769-2.213,4.769-4.861V47.418c0-1.613-0.799-3.12-2.133-4.025
|
|
||||||
c-1.334-0.904-3.031-1.09-4.528-0.494L258.569,98.057c-1.851,0.736-3.065,2.527-3.065,4.519l0.019,261.017
|
|
||||||
C255.521,365.216,256.331,366.732,257.679,367.634z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<svg v-else viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M19 2H6c-1.206 0-3 .799-3 3v14c0 2.201 1.794 3 3 3h15v-2H6.012C5.55 19.988 5 19.806 5 19c0-.101.009-.191.024-.273.112-.576.584-.717.988-.727H21V4a2 2 0 0 0-2-2zm0 9-2-1-2 1V4h4v7z" /></svg> -->
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ export default {
|
|||||||
direction: {
|
direction: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'right'
|
default: 'right'
|
||||||
}
|
},
|
||||||
|
disabled: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -25,38 +26,68 @@ export default {
|
|||||||
watch: {
|
watch: {
|
||||||
text() {
|
text() {
|
||||||
this.updateText()
|
this.updateText()
|
||||||
|
},
|
||||||
|
disabled(newVal) {
|
||||||
|
if (newVal && this.isShowing) {
|
||||||
|
this.hideTooltip()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
updateText() {
|
updateText() {
|
||||||
if (this.tooltip) {
|
if (this.tooltip) {
|
||||||
this.tooltip.innerHTML = this.text
|
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() {
|
createTooltip() {
|
||||||
if (!this.$refs.box) return
|
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 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 top = 0
|
||||||
var left = 0
|
var left = 0
|
||||||
if (this.direction === 'right') {
|
if (this.direction === 'right') {
|
||||||
top = boxChow.top
|
top = boxChow.top - height / 2 + boxChow.height / 2
|
||||||
left = boxChow.left + boxChow.width + 4
|
left = boxChow.left + boxChow.width + 4
|
||||||
} else if (this.direction === 'bottom') {
|
} else if (this.direction === 'bottom') {
|
||||||
top = boxChow.top + boxChow.height + 4
|
top = boxChow.top + boxChow.height + 4
|
||||||
left = boxChow.left
|
left = boxChow.left - width / 2 + boxChow.width / 2
|
||||||
} else if (this.direction === 'top') {
|
} else if (this.direction === 'top') {
|
||||||
top = boxChow.top - 24
|
top = boxChow.top - height - 4
|
||||||
left = boxChow.left
|
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.top = top + 'px'
|
||||||
tooltip.style.left = left + 'px'
|
tooltip.style.left = left + 'px'
|
||||||
tooltip.style.zIndex = 100
|
|
||||||
tooltip.innerHTML = this.text
|
|
||||||
this.tooltip = tooltip
|
|
||||||
},
|
},
|
||||||
showTooltip() {
|
showTooltip() {
|
||||||
|
if (this.disabled) return
|
||||||
if (!this.tooltip) {
|
if (!this.tooltip) {
|
||||||
this.createTooltip()
|
this.createTooltip()
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-13
@@ -102,6 +102,7 @@ export default {
|
|||||||
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
if (results.added) scanResultMsgs.push(`${results.added} added`)
|
||||||
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
|
||||||
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
|
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')
|
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
|
||||||
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
|
||||||
}
|
}
|
||||||
@@ -128,19 +129,18 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
downloadToastClick(download) {
|
downloadToastClick(download) {
|
||||||
console.log('Downlaod ready toast click', download)
|
if (!download || !download.audiobookId) {
|
||||||
// if (!download || !download.audiobookId) {
|
return console.error('Invalid download object', download)
|
||||||
// return console.error('Invalid download object', download)
|
}
|
||||||
// }
|
var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
||||||
// var audiobook = this.$store.getters['audiobooks/getAudiobook'](download.audiobookId)
|
if (!audiobook) {
|
||||||
// if (!audiobook) {
|
return console.error('Audiobook not found for download', download)
|
||||||
// return console.error('Audiobook not found for download', download)
|
}
|
||||||
// }
|
this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
||||||
// this.$store.commit('showEditModalOnTab', { audiobook, tab: 'download' })
|
|
||||||
},
|
},
|
||||||
downloadStarted(download) {
|
downloadStarted(download) {
|
||||||
download.status = this.$constants.DownloadStatus.PENDING
|
download.status = this.$constants.DownloadStatus.PENDING
|
||||||
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: this.downloadToastClick })
|
download.toastId = this.$toast(`Preparing download "${download.filename}"`, { timeout: false, draggable: false, closeOnClick: false, onClick: () => this.downloadToastClick(download) })
|
||||||
this.$store.commit('downloads/addUpdateDownload', download)
|
this.$store.commit('downloads/addUpdateDownload', download)
|
||||||
},
|
},
|
||||||
downloadReady(download) {
|
downloadReady(download) {
|
||||||
@@ -149,7 +149,7 @@ export default {
|
|||||||
|
|
||||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||||
download.toastId = existingDownload.toastId
|
download.toastId = existingDownload.toastId
|
||||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: this.downloadToastClick } }, true)
|
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" is ready!`, options: { timeout: 5000, type: 'success', onClick: () => this.downloadToastClick(download) } }, true)
|
||||||
} else {
|
} else {
|
||||||
this.$toast.success(`Download "${download.filename}" is ready!`)
|
this.$toast.success(`Download "${download.filename}" is ready!`)
|
||||||
}
|
}
|
||||||
@@ -163,7 +163,7 @@ export default {
|
|||||||
|
|
||||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||||
download.toastId = existingDownload.toastId
|
download.toastId = existingDownload.toastId
|
||||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" ${failedMsg}`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Download failed no existing download', existingDownload)
|
console.warn('Download failed no existing download', existingDownload)
|
||||||
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
this.$toast.error(`Download "${download.filename}" ${failedMsg}`)
|
||||||
@@ -174,7 +174,7 @@ export default {
|
|||||||
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
var existingDownload = this.$store.getters['downloads/getDownload'](download.id)
|
||||||
if (existingDownload && existingDownload.toastId !== undefined) {
|
if (existingDownload && existingDownload.toastId !== undefined) {
|
||||||
download.toastId = existingDownload.toastId
|
download.toastId = existingDownload.toastId
|
||||||
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: this.downloadToastClick } }, true)
|
this.$toast.update(existingDownload.toastId, { content: `Download "${download.filename}" was terminated`, options: { timeout: 5000, type: 'error', onClick: () => this.downloadToastClick(download) } }, true)
|
||||||
} else {
|
} else {
|
||||||
console.warn('Download killed no existing download found', existingDownload)
|
console.warn('Download killed no existing download found', existingDownload)
|
||||||
this.$toast.error(`Download "${download.filename}" was terminated`)
|
this.$toast.error(`Download "${download.filename}" was terminated`)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf-client",
|
"name": "audiobookshelf-client",
|
||||||
"version": "1.1.9",
|
"version": "1.1.13",
|
||||||
"description": "Audiobook manager and player",
|
"description": "Audiobook manager and player",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
|
||||||
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
|
||||||
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
<div class="w-min">
|
||||||
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
|
<ui-tooltip :text="authorTooltipText" direction="bottom">
|
||||||
</ui-tooltip>
|
<span class="text-sm text-gray-100 leading-7 whitespace-nowrap">by {{ author }}</span>
|
||||||
|
</ui-tooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
@@ -31,17 +33,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center pt-4">
|
<div class="flex items-center pt-4">
|
||||||
<ui-btn :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @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>
|
<span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||||
{{ streaming ? 'Streaming' : 'Play' }}
|
{{ streaming ? 'Streaming' : 'Play' }}
|
||||||
</ui-btn>
|
</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-tooltip v-if="userCanUpdate" text="Edit" direction="top">
|
||||||
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip v-if="userCanDownload" text="Download" direction="top">
|
<ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top">
|
||||||
<ui-icon-btn icon="download" class="mx-0.5" @click="downloadClick" />
|
<ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" />
|
||||||
</ui-tooltip>
|
</ui-tooltip>
|
||||||
|
|
||||||
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
<ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top">
|
||||||
@@ -69,7 +75,7 @@
|
|||||||
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span>
|
||||||
</p>
|
</p>
|
||||||
<div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,6 +158,9 @@ export default {
|
|||||||
})
|
})
|
||||||
return chunks
|
return chunks
|
||||||
},
|
},
|
||||||
|
isMissing() {
|
||||||
|
return this.audiobook.isMissing
|
||||||
|
},
|
||||||
missingParts() {
|
missingParts() {
|
||||||
return this.audiobook.missingParts || []
|
return this.audiobook.missingParts || []
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
<div id="page-wrapper" class="page p-6" :class="streamAudiobook ? 'streaming' : ''">
|
||||||
<main class="container mx-auto h-full max-w-screen-lg p-6">
|
<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">
|
<article class="max-h-full overflow-y-auto relative flex flex-col rounded-md" @drop="drop" @dragover="dragover" @dragleave="dragleave" @dragenter="dragenter">
|
||||||
<h1 class="text-xl font-book px-4 pt-4 pb-2"><span class="text-error pr-4">(Experimental)</span>Audiobook Uploader</h1>
|
<h1 class="text-xl font-book px-8 pt-4 pb-2">Audiobook Uploader</h1>
|
||||||
|
|
||||||
<div class="flex my-2 px-6">
|
<div class="flex my-2 px-6">
|
||||||
<div class="w-1/2 px-2">
|
<div class="w-1/2 px-2">
|
||||||
@@ -170,19 +170,16 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
drop(evt) {
|
drop(evt) {
|
||||||
console.log('Dropped event', evt)
|
|
||||||
this.isDragOver = false
|
this.isDragOver = false
|
||||||
this.preventDefaults(evt)
|
this.preventDefaults(evt)
|
||||||
const files = [...evt.dataTransfer.files]
|
const files = [...evt.dataTransfer.files]
|
||||||
this.filesChanged(files)
|
this.filesChanged(files)
|
||||||
},
|
},
|
||||||
dragover(evt) {
|
dragover(evt) {
|
||||||
console.log('Dragged over', evt)
|
|
||||||
this.isDragOver = true
|
this.isDragOver = true
|
||||||
this.preventDefaults(evt)
|
this.preventDefaults(evt)
|
||||||
},
|
},
|
||||||
dragleave(evt) {
|
dragleave(evt) {
|
||||||
console.log('Dragged leave', evt)
|
|
||||||
this.isDragOver = false
|
this.isDragOver = false
|
||||||
this.preventDefaults(evt)
|
this.preventDefaults(evt)
|
||||||
},
|
},
|
||||||
@@ -195,7 +192,6 @@ export default {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
},
|
},
|
||||||
filesChanged(files) {
|
filesChanged(files) {
|
||||||
console.log('FilesChanged', files)
|
|
||||||
this.showUploader = false
|
this.showUploader = false
|
||||||
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
|||||||
@@ -38,13 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
|||||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
Vue.prototype.$cleanString = (str) => {
|
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||||
if (!str) return ''
|
const el = document.createElement('p')
|
||||||
|
|
||||||
// No longer necessary to replace accented chars, full utf-8 charset is supported
|
let attr = 'margin:0px;opacity:1;position:absolute;top:100px;left:100px;z-index:99;'
|
||||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
for (const key in styles) {
|
||||||
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
if (styles[key] && String(styles[key]).length > 0) {
|
||||||
return str.trim()
|
attr += `${key}:${styles[key]};`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = []) {
|
function isClickedOutsideEl(clickEvent, elToCheckOutside, ignoreSelectors = [], ignoreElems = []) {
|
||||||
|
|||||||
@@ -45,9 +45,12 @@ export const getters = {
|
|||||||
var direction = settings.orderDesc ? 'desc' : 'asc'
|
var direction = settings.orderDesc ? 'desc' : 'asc'
|
||||||
|
|
||||||
var filtered = getters.getFiltered()
|
var filtered = getters.getFiltered()
|
||||||
|
var orderByNumber = settings.orderBy === 'book.volumeNumber'
|
||||||
return sort(filtered)[direction]((ab) => {
|
return sort(filtered)[direction]((ab) => {
|
||||||
// Supports dot notation strings i.e. "book.title"
|
// Supports dot notation strings i.e. "book.title"
|
||||||
return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
var value = settings.orderBy.split('.').reduce((a, b) => a[b], ab)
|
||||||
|
if (orderByNumber && !isNaN(value)) return Number(value)
|
||||||
|
return value
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
getUniqueAuthors: (state) => {
|
getUniqueAuthors: (state) => {
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ export const getters = {
|
|||||||
},
|
},
|
||||||
getUserCanDownload: (state) => {
|
getUserCanDownload: (state) => {
|
||||||
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
return state.user && state.user.permissions ? !!state.user.permissions.download : false
|
||||||
|
},
|
||||||
|
getUserCanUpload: (state) => {
|
||||||
|
return state.user && state.user.permissions ? !!state.user.permissions.upload : false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +63,8 @@ export const mutations = {
|
|||||||
state.user = user
|
state.user = user
|
||||||
if (user) {
|
if (user) {
|
||||||
if (user.token) localStorage.setItem('token', user.token)
|
if (user.token) localStorage.setItem('token', user.token)
|
||||||
console.log('setUser', user.username)
|
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('token')
|
localStorage.removeItem('token')
|
||||||
console.warn('setUser cleared')
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setSettings(state, settings) {
|
setSettings(state, settings) {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.1.9",
|
"version": "1.1.13",
|
||||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,31 +9,61 @@ Android app is in beta, try it out on the [Google Play Store](https://play.googl
|
|||||||
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
<img alt="Screenshot1" src="https://github.com/advplyr/audiobookshelf/raw/master/images/ss_streaming.png" />
|
||||||
|
|
||||||
|
|
||||||
#### Folder Structures Supported:
|
## Directory Structure
|
||||||
|
|
||||||
```bash
|
Author, Series, Volume Number, Title and Publish Year can all be parsed from your folder structure.
|
||||||
/Title/...
|
|
||||||
/Author/Title/...
|
|
||||||
/Author/Series/Title/...
|
|
||||||
|
|
||||||
Title can start with the publish year like so:
|
**Note**: Files in the root directory `/audiobooks` will be ignored, all audiobooks should be in a directory
|
||||||
/1989 - Book Title/...
|
|
||||||
|
|
||||||
(Optional Setting) Subtitle can be seperated to its own field:
|
**1 Folder:** `/Title/...`\
|
||||||
/Book Title - With a Subtitle/...
|
**2 Folders:** `/Author/Title/...`\
|
||||||
/1989 - Book Title - With a Subtitle/...
|
**3 Folders:** `/Author/Series/Title/...`
|
||||||
will store "With a Subtitle" as the subtitle
|
|
||||||
```
|
### Parsing publish year
|
||||||
|
|
||||||
|
`/1984 - Hackers/...`\
|
||||||
|
Will save the publish year as `1984` and the title as `Hackers`
|
||||||
|
|
||||||
|
### Parsing volume number (only for series)
|
||||||
|
|
||||||
|
`/Book 3 - Hackers/...`\
|
||||||
|
Will save the volume number as `3` and the title as `Hackers`
|
||||||
|
|
||||||
|
`Book` `Volume` `Vol` `Vol.` are all supported case insensitive
|
||||||
|
|
||||||
|
These combinations will also work:\
|
||||||
|
`/Hackers - Vol. 3/...`\
|
||||||
|
`/1984 - Volume 3 - Hackers/...`\
|
||||||
|
`/1984 - Hackers Book 3/...`
|
||||||
|
|
||||||
|
|
||||||
#### Features coming soon:
|
### Parsing subtitles (optional in settings)
|
||||||
|
|
||||||
|
Title Folder: `/Hackers - Heroes of the Computer Revolution/...`
|
||||||
|
|
||||||
|
Will save the title as `Hackers` and the subtitle as `Heroes of the Computer Revolution`
|
||||||
|
|
||||||
|
|
||||||
|
### Full example
|
||||||
|
|
||||||
|
`/Steven Levy/The Hacker Series/1984 - Hackers - Heroes of the Computer Revolution - Vol. 1/...`
|
||||||
|
|
||||||
|
**Becomes:**
|
||||||
|
| Key | Value |
|
||||||
|
|---------------|-----------------------------------|
|
||||||
|
| Author | Steven Levy |
|
||||||
|
| Series | The Hacker Series |
|
||||||
|
| Publish Year | 1984 |
|
||||||
|
| Title | Hackers |
|
||||||
|
| Subtitle | Heroes of the Computer Revolution |
|
||||||
|
| Volume Number | 1 |
|
||||||
|
|
||||||
|
|
||||||
|
## Features coming soon
|
||||||
|
|
||||||
* Support different views to see more details of each audiobook
|
* 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))
|
* 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" />
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Built to run in Docker for now (also on Unraid server Community Apps)
|
Built to run in Docker for now (also on Unraid server Community Apps)
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ApiController {
|
|||||||
|
|
||||||
this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.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/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/password', this.userChangePassword.bind(this))
|
||||||
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
this.router.patch('/user/settings', this.userUpdateSettings.bind(this))
|
||||||
this.router.get('/users', this.getUsers.bind(this))
|
this.router.get('/users', this.getUsers.bind(this))
|
||||||
@@ -271,6 +273,26 @@ class ApiController {
|
|||||||
res.sendStatus(200)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
userChangePassword(req, res) {
|
userChangePassword(req, res) {
|
||||||
this.auth.userChangePassword(req, res)
|
this.auth.userChangePassword(req, res)
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-24
@@ -9,7 +9,6 @@ const { comparePaths, getIno } = require('./utils/index')
|
|||||||
const { secondsToTimestamp } = require('./utils/fileUtils')
|
const { secondsToTimestamp } = require('./utils/fileUtils')
|
||||||
const { ScanResult } = require('./utils/constants')
|
const { ScanResult } = require('./utils/constants')
|
||||||
|
|
||||||
|
|
||||||
class Scanner {
|
class Scanner {
|
||||||
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) {
|
||||||
this.AudiobookPath = AUDIOBOOK_PATH
|
this.AudiobookPath = AUDIOBOOK_PATH
|
||||||
@@ -71,6 +70,7 @@ class Scanner {
|
|||||||
if (existingAudiobook) {
|
if (existingAudiobook) {
|
||||||
|
|
||||||
// REMOVE: No valid audio files
|
// REMOVE: No valid audio files
|
||||||
|
// TODO: Label as incomplete, do not actually delete
|
||||||
if (!audiobookData.audioFiles.length) {
|
if (!audiobookData.audioFiles.length) {
|
||||||
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`)
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@ class Scanner {
|
|||||||
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// REMOVE: No valid audio tracks
|
// REMOVE: No valid audio tracks
|
||||||
|
// TODO: Label as incomplete, do not actually delete
|
||||||
if (!existingAudiobook.tracks.length) {
|
if (!existingAudiobook.tracks.length) {
|
||||||
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`)
|
||||||
|
|
||||||
@@ -135,6 +135,12 @@ class Scanner {
|
|||||||
hasUpdates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (existingAudiobook.isMissing) {
|
||||||
|
existingAudiobook.isMissing = false
|
||||||
|
hasUpdates = true
|
||||||
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
||||||
|
}
|
||||||
|
|
||||||
if (hasUpdates) {
|
if (hasUpdates) {
|
||||||
existingAudiobook.setChapters()
|
existingAudiobook.setChapters()
|
||||||
|
|
||||||
@@ -173,23 +179,24 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async scan() {
|
async scan() {
|
||||||
|
// TODO: This temporary fix from pre-release should be removed soon, including the "fixRelativePath" and "checkUpdateInos"
|
||||||
// TEMP - fix relative file paths
|
// TEMP - fix relative file paths
|
||||||
// TEMP - update ino for each audiobook
|
// TEMP - update ino for each audiobook
|
||||||
if (this.audiobooks.length) {
|
// if (this.audiobooks.length) {
|
||||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
// for (let i = 0; i < this.audiobooks.length; i++) {
|
||||||
var ab = this.audiobooks[i]
|
// var ab = this.audiobooks[i]
|
||||||
var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
// var shouldUpdate = ab.fixRelativePath(this.AudiobookPath) || !ab.ino
|
||||||
|
|
||||||
// Update ino if an audio file has the same ino as the audiobook
|
// // 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)
|
// var shouldUpdateIno = !ab.ino || (ab.audioFiles || []).find(abf => abf.ino === ab.ino)
|
||||||
if (shouldUpdateIno) {
|
// if (shouldUpdateIno) {
|
||||||
await ab.checkUpdateInos()
|
// await ab.checkUpdateInos()
|
||||||
}
|
// }
|
||||||
if (shouldUpdate) {
|
// if (shouldUpdate) {
|
||||||
await this.db.updateAudiobook(ab)
|
// await this.db.updateAudiobook(ab)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
const scanStart = Date.now()
|
const scanStart = Date.now()
|
||||||
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
var audiobookDataFound = await scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||||
@@ -205,18 +212,21 @@ class Scanner {
|
|||||||
var scanResults = {
|
var scanResults = {
|
||||||
removed: 0,
|
removed: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
added: 0
|
added: 0,
|
||||||
|
missing: 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for removed audiobooks
|
// Check for removed audiobooks
|
||||||
for (let i = 0; i < this.audiobooks.length; i++) {
|
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) {
|
if (!dataFound) {
|
||||||
Logger.info(`[Scanner] Removing audiobook "${this.audiobooks[i].title}" - no longer in dir`)
|
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
|
||||||
var audiobookJSON = this.audiobooks[i].toJSONMinified()
|
audiobook.isMissing = true
|
||||||
await this.db.removeEntity('audiobook', this.audiobooks[i].id)
|
audiobook.lastUpdate = Date.now()
|
||||||
scanResults.removed++
|
scanResults.missing++
|
||||||
this.emitter('audiobook_removed', audiobookJSON)
|
await this.db.updateAudiobook(audiobook)
|
||||||
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
||||||
}
|
}
|
||||||
if (this.cancelScan) {
|
if (this.cancelScan) {
|
||||||
this.cancelScan = false
|
this.cancelScan = false
|
||||||
@@ -247,7 +257,7 @@ class Scanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
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
|
return scanResults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+46
-33
@@ -123,6 +123,51 @@ class Server {
|
|||||||
this.auth.authMiddleware(req, res, next)
|
this.auth.authMiddleware(req, res, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleUpload(req, res) {
|
||||||
|
if (!req.user.canUpload) {
|
||||||
|
Logger.warn('User attempted to upload without permission', req.user)
|
||||||
|
return res.sendStatus(403)
|
||||||
|
}
|
||||||
|
var files = Object.values(req.files)
|
||||||
|
var title = req.body.title
|
||||||
|
var author = req.body.author
|
||||||
|
var series = req.body.series
|
||||||
|
|
||||||
|
if (!files.length || !title || !author) {
|
||||||
|
return res.json({
|
||||||
|
error: 'Invalid post data received'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputDirectory = ''
|
||||||
|
if (series && series.length && series !== 'null') {
|
||||||
|
outputDirectory = Path.join(this.AudiobookPath, author, series, title)
|
||||||
|
} else {
|
||||||
|
outputDirectory = Path.join(this.AudiobookPath, author, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists = await fs.pathExists(outputDirectory)
|
||||||
|
if (exists) {
|
||||||
|
Logger.error(`[Server] Upload directory "${outputDirectory}" already exists`)
|
||||||
|
return res.json({
|
||||||
|
error: `Directory "${outputDirectory}" already exists`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.ensureDir(outputDirectory)
|
||||||
|
Logger.info(`Uploading ${files.length} files to`, outputDirectory)
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
var file = files[i]
|
||||||
|
|
||||||
|
var path = Path.join(outputDirectory, file.name)
|
||||||
|
await file.mv(path).catch((error) => {
|
||||||
|
Logger.error('Failed to move file', path, error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
res.sendStatus(200)
|
||||||
|
}
|
||||||
|
|
||||||
async start() {
|
async start() {
|
||||||
Logger.info('=== Starting Server ===')
|
Logger.info('=== Starting Server ===')
|
||||||
|
|
||||||
@@ -157,38 +202,7 @@ class Server {
|
|||||||
// app.use('/hls', this.hlsController.router)
|
// app.use('/hls', this.hlsController.router)
|
||||||
app.use('/feeds', this.rssFeeds.router)
|
app.use('/feeds', this.rssFeeds.router)
|
||||||
|
|
||||||
app.post('/upload', this.authMiddleware.bind(this), async (req, res) => {
|
app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this))
|
||||||
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('/login', (req, res) => this.auth.login(req, res))
|
||||||
app.post('/logout', this.logout.bind(this))
|
app.post('/logout', this.logout.bind(this))
|
||||||
@@ -197,7 +211,6 @@ class Server {
|
|||||||
res.json({ success: true })
|
res.json({ success: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Used in development to set-up streams without authentication
|
// Used in development to set-up streams without authentication
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
app.use('/test-hls', this.hlsController.router)
|
app.use('/test-hls', this.hlsController.router)
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ class Audiobook {
|
|||||||
this.book = null
|
this.book = null
|
||||||
this.chapters = []
|
this.chapters = []
|
||||||
|
|
||||||
|
// Audiobook was scanned and not found
|
||||||
|
this.isMissing = false
|
||||||
|
|
||||||
if (audiobook) {
|
if (audiobook) {
|
||||||
this.construct(audiobook)
|
this.construct(audiobook)
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,8 @@ class Audiobook {
|
|||||||
if (audiobook.chapters) {
|
if (audiobook.chapters) {
|
||||||
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.isMissing = !!audiobook.isMissing
|
||||||
}
|
}
|
||||||
|
|
||||||
get title() {
|
get title() {
|
||||||
@@ -127,7 +132,8 @@ class Audiobook {
|
|||||||
tracks: this.tracksToJSON(),
|
tracks: this.tracksToJSON(),
|
||||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.toJSON()),
|
||||||
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
otherFiles: (this.otherFiles || []).map(otherFile => otherFile.toJSON()),
|
||||||
chapters: this.chapters || []
|
chapters: this.chapters || [],
|
||||||
|
isMissing: !!this.isMissing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +153,8 @@ class Audiobook {
|
|||||||
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
hasMissingParts: this.missingParts ? this.missingParts.length : 0,
|
||||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||||
numTracks: this.tracks.length,
|
numTracks: this.tracks.length,
|
||||||
chapters: this.chapters || []
|
chapters: this.chapters || [],
|
||||||
|
isMissing: !!this.isMissing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +176,8 @@ class Audiobook {
|
|||||||
tags: this.tags,
|
tags: this.tags,
|
||||||
book: this.bookToJSON(),
|
book: this.bookToJSON(),
|
||||||
tracks: this.tracksToJSON(),
|
tracks: this.tracksToJSON(),
|
||||||
chapters: this.chapters || []
|
chapters: this.chapters || [],
|
||||||
|
isMissing: !!this.isMissing
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ class Book {
|
|||||||
// If audiobook directory path was changed, check and update properties set from dirnames
|
// If audiobook directory path was changed, check and update properties set from dirnames
|
||||||
// May be worthwhile checking if these were manually updated and not override manual updates
|
// May be worthwhile checking if these were manually updated and not override manual updates
|
||||||
syncPathsUpdated(audiobookData) {
|
syncPathsUpdated(audiobookData) {
|
||||||
var keysToSync = ['author', 'title', 'series', 'publishYear']
|
var keysToSync = ['author', 'title', 'series', 'publishYear', 'volumeNumber']
|
||||||
var syncPayload = {}
|
var syncPayload = {}
|
||||||
keysToSync.forEach((key) => {
|
keysToSync.forEach((key) => {
|
||||||
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
|
if (audiobookData[key]) syncPayload[key] = audiobookData[key]
|
||||||
|
|||||||
@@ -171,13 +171,11 @@ class Stream extends EventEmitter {
|
|||||||
this.furthestSegmentCreated = lastSegment
|
this.furthestSegmentCreated = lastSegment
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('SORT', [...this.segmentsCreated].slice(0, 200).join(', '), segments.slice(0, 200).join(', '))
|
|
||||||
segments.forEach((seg) => {
|
segments.forEach((seg) => {
|
||||||
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
|
if (!current_chunk.length || last_seg_in_chunk + 1 === seg) {
|
||||||
last_seg_in_chunk = seg
|
last_seg_in_chunk = seg
|
||||||
current_chunk.push(seg)
|
current_chunk.push(seg)
|
||||||
} else {
|
} else {
|
||||||
// console.log('Last Seg is not equal to - 1', last_seg_in_chunk, seg)
|
|
||||||
if (current_chunk.length === 1) chunks.push(current_chunk[0])
|
if (current_chunk.length === 1) chunks.push(current_chunk[0])
|
||||||
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
|
else chunks.push(`${current_chunk[0]}-${current_chunk[current_chunk.length - 1]}`)
|
||||||
last_seg_in_chunk = seg
|
last_seg_in_chunk = seg
|
||||||
@@ -288,6 +286,7 @@ class Stream extends EventEmitter {
|
|||||||
} else {
|
} else {
|
||||||
Logger.error('Ffmpeg Err', err.message)
|
Logger.error('Ffmpeg Err', err.message)
|
||||||
}
|
}
|
||||||
|
clearInterval(this.loop)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.ffmpeg.on('end', (stdout, stderr) => {
|
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||||
@@ -300,6 +299,7 @@ class Stream extends EventEmitter {
|
|||||||
}
|
}
|
||||||
this.isTranscodeComplete = true
|
this.isTranscodeComplete = true
|
||||||
this.ffmpeg = null
|
this.ffmpeg = null
|
||||||
|
clearInterval(this.loop)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.ffmpeg.run()
|
this.ffmpeg.run()
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ class User {
|
|||||||
get canDownload() {
|
get canDownload() {
|
||||||
return !!this.permissions.download && this.isActive
|
return !!this.permissions.download && this.isActive
|
||||||
}
|
}
|
||||||
|
get canUpload() {
|
||||||
|
return !!this.permissions.upload && this.isActive
|
||||||
|
}
|
||||||
|
|
||||||
getDefaultUserSettings() {
|
getDefaultUserSettings() {
|
||||||
return {
|
return {
|
||||||
@@ -47,7 +50,8 @@ class User {
|
|||||||
return {
|
return {
|
||||||
download: true,
|
download: true,
|
||||||
update: true,
|
update: true,
|
||||||
delete: this.id === 'root'
|
delete: this.type === 'root',
|
||||||
|
upload: this.type === 'root' || this.type === 'admin'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +116,8 @@ class User {
|
|||||||
this.createdAt = user.createdAt || Date.now()
|
this.createdAt = user.createdAt || Date.now()
|
||||||
this.settings = user.settings || this.getDefaultUserSettings()
|
this.settings = user.settings || this.getDefaultUserSettings()
|
||||||
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
this.permissions = user.permissions || this.getDefaultUserPermissions()
|
||||||
|
// Upload permission added v1.1.13, make sure root user has upload permissions
|
||||||
|
if (this.type === 'root' && !this.permissions.upload) this.permissions.upload = true
|
||||||
}
|
}
|
||||||
|
|
||||||
update(payload) {
|
update(payload) {
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var tracks = []
|
var tracks = []
|
||||||
|
var numDuplicateTracks = 0
|
||||||
|
var numInvalidTracks = 0
|
||||||
for (let i = 0; i < newAudioFiles.length; i++) {
|
for (let i = 0; i < newAudioFiles.length; i++) {
|
||||||
var audioFile = newAudioFiles[i]
|
var audioFile = newAudioFiles[i]
|
||||||
var scanData = await scan(audioFile.fullPath)
|
var scanData = await scan(audioFile.fullPath)
|
||||||
@@ -118,17 +120,19 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
|||||||
if (newAudioFiles.length > 1) {
|
if (newAudioFiles.length > 1) {
|
||||||
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
|
trackNumber = isNumber(trackNumFromMeta) ? trackNumFromMeta : trackNumFromFilename
|
||||||
if (trackNumber === null) {
|
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.invalid = true
|
||||||
audioFile.error = 'Failed to get track number'
|
audioFile.error = 'Failed to get track number'
|
||||||
|
numInvalidTracks++
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tracks.find(t => t.index === trackNumber)) {
|
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.invalid = true
|
||||||
audioFile.error = 'Duplicate track number'
|
audioFile.error = 'Duplicate track number'
|
||||||
|
numDuplicateTracks++
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +145,13 @@ async function scanAudioFiles(audiobook, newAudioFiles) {
|
|||||||
return
|
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)
|
tracks.sort((a, b) => a.index - b.index)
|
||||||
audiobook.audioFiles.sort((a, b) => {
|
audiobook.audioFiles.sort((a, b) => {
|
||||||
var aNum = isNumber(a.trackNumFromMeta) ? a.trackNumFromMeta : isNumber(a.trackNumFromFilename) ? a.trackNumFromFilename : 0
|
var aNum = isNumber(a.trackNumFromMeta) ? a.trackNumFromMeta : isNumber(a.trackNumFromFilename) ? a.trackNumFromFilename : 0
|
||||||
|
|||||||
+74
-6
@@ -19,11 +19,17 @@ 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) {
|
function groupFilesIntoAudiobookPaths(paths) {
|
||||||
// Step 1: Normalize path, Remove leading "/", Filter out files in root dir
|
// 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)
|
var pathsFiltered = paths.map(path => Path.normalize(path.slice(1))).filter(path => Path.parse(path).dir)
|
||||||
|
|
||||||
|
|
||||||
// Step 2: Sort by least number of directories
|
// Step 2: Sort by least number of directories
|
||||||
pathsFiltered.sort((a, b) => {
|
pathsFiltered.sort((a, b) => {
|
||||||
var pathsA = Path.dirname(a).split(Path.sep).length
|
var pathsA = Path.dirname(a).split(Path.sep).length
|
||||||
@@ -31,25 +37,55 @@ function groupFilesIntoAudiobookPaths(paths) {
|
|||||||
return pathsA - pathsB
|
return pathsA - pathsB
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 3: Group into audiobooks
|
// 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 = {}
|
var audiobookGroup = {}
|
||||||
pathsFiltered.forEach((path) => {
|
audioFilePaths.forEach((path) => {
|
||||||
var dirparts = Path.dirname(path).split(Path.sep)
|
var dirparts = Path.dirname(path).split(Path.sep)
|
||||||
var numparts = dirparts.length
|
var numparts = dirparts.length
|
||||||
var _path = ''
|
var _path = ''
|
||||||
|
|
||||||
|
// Iterate over directories in path
|
||||||
for (let i = 0; i < numparts; i++) {
|
for (let i = 0; i < numparts; i++) {
|
||||||
var dirpart = dirparts.shift()
|
var dirpart = dirparts.shift()
|
||||||
_path = Path.join(_path, dirpart)
|
_path = Path.join(_path, dirpart)
|
||||||
if (audiobookGroup[_path]) {
|
|
||||||
|
|
||||||
|
if (audiobookGroup[_path]) { // Directory already has files, add file
|
||||||
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
|
var relpath = Path.join(dirparts.join(Path.sep), Path.basename(path))
|
||||||
audiobookGroup[_path].push(relpath)
|
audiobookGroup[_path].push(relpath)
|
||||||
return
|
return
|
||||||
} else if (!dirparts.length) {
|
} else if (!dirparts.length) { // This is the last directory, create group
|
||||||
audiobookGroup[_path] = [Path.basename(path)]
|
audiobookGroup[_path] = [Path.basename(path)]
|
||||||
return
|
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
|
return audiobookGroup
|
||||||
}
|
}
|
||||||
module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths
|
module.exports.groupFilesIntoAudiobookPaths = groupFilesIntoAudiobookPaths
|
||||||
@@ -119,10 +155,39 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
|||||||
// If there are at least 2 more directories, next furthest will be the series
|
// If there are at least 2 more directories, next furthest will be the series
|
||||||
if (splitDir.length > 1) series = splitDir.pop()
|
if (splitDir.length > 1) series = splitDir.pop()
|
||||||
if (splitDir.length > 0) author = 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/
|
// There could be many more directories, but only the top 3 are used for naming /author/series/title/
|
||||||
|
|
||||||
|
|
||||||
|
// If in a series directory check for volume number match
|
||||||
|
/* ACCEPTS:
|
||||||
|
Book 2 - Title Here - Subtitle Here
|
||||||
|
Title Here - Subtitle Here - Vol 12
|
||||||
|
Title Here - volume 9 - Subtitle Here
|
||||||
|
Vol. 3 Title Here - Subtitle Here
|
||||||
|
1980 - Book 2-Title Here
|
||||||
|
Title Here-Volume 999-Subtitle Here
|
||||||
|
*/
|
||||||
|
var volumeNumber = null
|
||||||
|
if (series) {
|
||||||
|
var volumeMatch = title.match(/(-(?: ?))?\b((?:Book|Vol.?|Volume) \b(\d{1,3}))((?: ?)-)?/i)
|
||||||
|
if (volumeMatch && volumeMatch.length > 3 && volumeMatch[2] && volumeMatch[3]) {
|
||||||
|
volumeNumber = volumeMatch[3]
|
||||||
|
var replaceChunk = volumeMatch[2]
|
||||||
|
|
||||||
|
// "1980 - Book 2-Title Here"
|
||||||
|
// Group 1 would be "- "
|
||||||
|
// Group 3 would be "-"
|
||||||
|
// Only remove the first group
|
||||||
|
if (volumeMatch[1]) {
|
||||||
|
replaceChunk = volumeMatch[1] + replaceChunk
|
||||||
|
} else if (volumeMatch[4]) {
|
||||||
|
replaceChunk += volumeMatch[4]
|
||||||
|
}
|
||||||
|
title = title.replace(replaceChunk, '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
var publishYear = null
|
var publishYear = null
|
||||||
// If Title is of format 1999 - Title, then use 1999 as publish year
|
// If Title is of format 1999 - Title, then use 1999 as publish year
|
||||||
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
|
var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/)
|
||||||
@@ -133,7 +198,9 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Subtitle can be parsed from the title if user enabled
|
// Subtitle can be parsed from the title if user enabled
|
||||||
|
// Subtitle is everything after " - "
|
||||||
var subtitle = null
|
var subtitle = null
|
||||||
if (parseSubtitle && title.includes(' - ')) {
|
if (parseSubtitle && title.includes(' - ')) {
|
||||||
var splitOnSubtitle = title.split(' - ')
|
var splitOnSubtitle = title.split(' - ')
|
||||||
@@ -146,6 +213,7 @@ function getAudiobookDataFromDir(abRootPath, dir, parseSubtitle = false) {
|
|||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
series,
|
series,
|
||||||
|
volumeNumber,
|
||||||
publishYear,
|
publishYear,
|
||||||
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
path: dir, // relative audiobook path i.e. /Author Name/Book Name/..
|
||||||
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
fullPath: Path.join(abRootPath, dir) // i.e. /audiobook/Author Name/Book Name/..
|
||||||
|
|||||||
Reference in New Issue
Block a user