mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-06-01 16:30:39 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db01db3a2b | |||
| 0851a1e71e | |||
| 0addfc8269 | |||
| b2ab5730f5 | |||
| 7859d7a502 |
@@ -11,6 +11,11 @@
|
||||
<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>
|
||||
@@ -34,10 +39,16 @@
|
||||
<ui-btn small class="text-sm mx-2" @click="toggleSelectAll">{{ isAllSelected ? 'Select None' : 'Select All' }}</ui-btn>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,6 +84,9 @@ 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']()
|
||||
},
|
||||
@@ -81,6 +95,16 @@ export default {
|
||||
},
|
||||
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: {
|
||||
@@ -103,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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<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>
|
||||
@@ -24,7 +24,7 @@
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +132,10 @@ export default {
|
||||
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
|
||||
@@ -141,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.`
|
||||
|
||||
@@ -109,6 +109,7 @@ export default {
|
||||
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
|
||||
@@ -122,6 +123,9 @@ export default {
|
||||
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 || {}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,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>
|
||||
<th v-if="showDownload" class="text-center">Download</th>
|
||||
</tr>
|
||||
<template v-for="track in tracks">
|
||||
<tr :key="track.index">
|
||||
@@ -27,7 +27,7 @@
|
||||
<td class="font-mono">
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -64,6 +64,12 @@ export default {
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
showDownload() {
|
||||
return this.userCanDownload && !this.isMissing
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
</button>
|
||||
</template>
|
||||
@@ -8,12 +8,22 @@
|
||||
export default {
|
||||
props: {
|
||||
icon: String,
|
||||
disabled: Boolean
|
||||
disabled: Boolean,
|
||||
bgColor: {
|
||||
type: String,
|
||||
default: 'primary'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
className() {
|
||||
var classes = []
|
||||
classes.push(`bg-${this.bgColor}`)
|
||||
return classes.join(' ')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickBtn(e) {
|
||||
if (this.disabled) {
|
||||
@@ -29,6 +39,9 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
button.icon-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
button.icon-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -96,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)
|
||||
@@ -105,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)
|
||||
@@ -116,7 +116,7 @@ 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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,32 +7,6 @@
|
||||
<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>
|
||||
<!-- <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>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -14,7 +14,8 @@ export default {
|
||||
direction: {
|
||||
type: String,
|
||||
default: 'right'
|
||||
}
|
||||
},
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -25,38 +26,68 @@ export default {
|
||||
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 - 24
|
||||
left = boxChow.left
|
||||
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.innerHTML = this.text
|
||||
this.tooltip = tooltip
|
||||
},
|
||||
showTooltip() {
|
||||
if (this.disabled) return
|
||||
if (!this.tooltip) {
|
||||
this.createTooltip()
|
||||
}
|
||||
|
||||
+50
-14
@@ -102,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'))
|
||||
}
|
||||
@@ -128,19 +129,18 @@ export default {
|
||||
}
|
||||
},
|
||||
downloadToastClick(download) {
|
||||
console.log('Downlaod ready toast click', 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' })
|
||||
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) {
|
||||
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)
|
||||
},
|
||||
downloadReady(download) {
|
||||
@@ -149,7 +149,7 @@ export default {
|
||||
|
||||
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 } }, true)
|
||||
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!`)
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export default {
|
||||
|
||||
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 } }, true)
|
||||
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}`)
|
||||
@@ -174,7 +174,7 @@ export default {
|
||||
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 } }, true)
|
||||
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`)
|
||||
@@ -232,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)
|
||||
@@ -243,4 +273,10 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.Vue-Toastification__toast-body.custom-class-1 {
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "1.1.8",
|
||||
"version": "1.1.12",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -31,17 +31,21 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{{ streaming ? 'Streaming' : 'Play' }}
|
||||
</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" text="Download" direction="top">
|
||||
<ui-icon-btn icon="download" class="mx-0.5" @click="downloadClick" />
|
||||
<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">
|
||||
@@ -69,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>
|
||||
|
||||
@@ -152,6 +156,9 @@ export default {
|
||||
})
|
||||
return chunks
|
||||
},
|
||||
isMissing() {
|
||||
return this.audiobook.isMissing
|
||||
},
|
||||
missingParts() {
|
||||
return this.audiobook.missingParts || []
|
||||
},
|
||||
|
||||
@@ -38,13 +38,26 @@ Vue.prototype.$secondsToTimestamp = (seconds) => {
|
||||
return `${_hours}:${_minutes.toString().padStart(2, '0')}:${_seconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
Vue.prototype.$cleanString = (str) => {
|
||||
if (!str) return ''
|
||||
Vue.prototype.$calculateTextSize = (text, styles = {}) => {
|
||||
const el = document.createElement('p')
|
||||
|
||||
// No longer necessary to replace accented chars, full utf-8 charset is supported
|
||||
// replace accented characters: https://stackoverflow.com/a/49901740/7431543
|
||||
// str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
return str.trim()
|
||||
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]};`
|
||||
}
|
||||
}
|
||||
|
||||
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 = []) {
|
||||
|
||||
@@ -7,4 +7,4 @@ const options = {
|
||||
draggable: false
|
||||
}
|
||||
|
||||
Vue.use(Toast, options)
|
||||
Vue.use(Toast, options)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -45,9 +45,12 @@ 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) => {
|
||||
|
||||
+16
-1
@@ -1,6 +1,7 @@
|
||||
import Vue from 'vue'
|
||||
import { checkForUpdate } from '@/plugins/version'
|
||||
|
||||
export const state = () => ({
|
||||
versionData: null,
|
||||
serverSettings: null,
|
||||
streamAudiobook: null,
|
||||
editModalTab: 'details',
|
||||
@@ -39,10 +40,24 @@ export const actions = {
|
||||
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
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "1.1.8",
|
||||
"version": "1.1.12",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -29,7 +29,6 @@ will store "With a Subtitle" as the subtitle
|
||||
#### Features coming soon:
|
||||
|
||||
* 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" />
|
||||
|
||||
@@ -37,6 +37,8 @@ class ApiController {
|
||||
|
||||
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))
|
||||
@@ -271,6 +273,26 @@ class ApiController {
|
||||
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) {
|
||||
this.auth.userChangePassword(req, res)
|
||||
}
|
||||
|
||||
+34
-24
@@ -9,7 +9,6 @@ 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) {
|
||||
this.AudiobookPath = AUDIOBOOK_PATH
|
||||
@@ -71,6 +70,7 @@ class Scanner {
|
||||
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`)
|
||||
|
||||
@@ -109,8 +109,8 @@ class Scanner {
|
||||
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`)
|
||||
|
||||
@@ -135,6 +135,12 @@ class Scanner {
|
||||
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()
|
||||
|
||||
@@ -173,23 +179,24 @@ class Scanner {
|
||||
}
|
||||
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
// // 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 scanRootDir(this.AudiobookPath, this.db.serverSettings)
|
||||
@@ -205,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
|
||||
@@ -247,7 +257,7 @@ 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
|
||||
}
|
||||
|
||||
|
||||
@@ -197,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)
|
||||
|
||||
@@ -28,6 +28,9 @@ class Audiobook {
|
||||
this.book = null
|
||||
this.chapters = []
|
||||
|
||||
// Audiobook was scanned and not found
|
||||
this.isMissing = false
|
||||
|
||||
if (audiobook) {
|
||||
this.construct(audiobook)
|
||||
}
|
||||
@@ -55,6 +58,8 @@ class Audiobook {
|
||||
if (audiobook.chapters) {
|
||||
this.chapters = audiobook.chapters.map(c => ({ ...c }))
|
||||
}
|
||||
|
||||
this.isMissing = !!audiobook.isMissing
|
||||
}
|
||||
|
||||
get title() {
|
||||
@@ -127,7 +132,8 @@ class Audiobook {
|
||||
tracks: this.tracksToJSON(),
|
||||
audioFiles: (this.audioFiles || []).map(audioFile => audioFile.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,
|
||||
hasInvalidParts: this.invalidParts ? this.invalidParts.length : 0,
|
||||
numTracks: this.tracks.length,
|
||||
chapters: this.chapters || []
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +176,8 @@ class Audiobook {
|
||||
tags: this.tags,
|
||||
book: this.bookToJSON(),
|
||||
tracks: this.tracksToJSON(),
|
||||
chapters: this.chapters || []
|
||||
chapters: this.chapters || [],
|
||||
isMissing: !!this.isMissing
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -288,6 +288,7 @@ class Stream extends EventEmitter {
|
||||
} else {
|
||||
Logger.error('Ffmpeg Err', err.message)
|
||||
}
|
||||
clearInterval(this.loop)
|
||||
})
|
||||
|
||||
this.ffmpeg.on('end', (stdout, stderr) => {
|
||||
@@ -300,6 +301,7 @@ class Stream extends EventEmitter {
|
||||
}
|
||||
this.isTranscodeComplete = true
|
||||
this.ffmpeg = null
|
||||
clearInterval(this.loop)
|
||||
})
|
||||
|
||||
this.ffmpeg.run()
|
||||
|
||||
@@ -89,6 +89,8 @@ 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)
|
||||
@@ -118,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;
|
||||
}
|
||||
|
||||
@@ -141,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
|
||||
|
||||
+41
-5
@@ -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) {
|
||||
// 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
|
||||
@@ -31,25 +37,55 @@ function groupFilesIntoAudiobookPaths(paths) {
|
||||
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 = {}
|
||||
pathsFiltered.forEach((path) => {
|
||||
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]) {
|
||||
|
||||
|
||||
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) {
|
||||
} 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
|
||||
|
||||
Reference in New Issue
Block a user