Compare commits

...

10 Commits

Author SHA1 Message Date
Mark Cooper 1d97422011 Readme update 2021-09-18 12:53:52 -05:00
Mark Cooper 2f2a64b89e Readme update 2021-09-18 12:53:14 -05:00
Mark Cooper b2e129eec7 Readme update 2021-09-18 12:52:38 -05:00
Mark Cooper cb79e48685 Readme Update 2021-09-18 12:50:22 -05:00
Mark Cooper e735ef7869 Readme update 2021-09-18 12:49:21 -05:00
Mark Cooper 8f1152762a Adding upload permission to users, directory structure readme update 2021-09-18 12:45:34 -05:00
Mark Cooper 587adb3773 Add volume number parsing to scanner 2021-09-18 11:13:05 -05:00
Mark Cooper db01db3a2b Missing audiobooks flagged not deleted, fix close progress loop on stream errors, clickable download toast, consolidate duplicate track error log, improved scanner to ignore non-audio files 2021-09-17 18:40:30 -05:00
Mark Cooper 0851a1e71e Fix sort by volume number, show batch read/not read update for users 2021-09-17 14:15:15 -05:00
Mark Cooper 0addfc8269 Readme upcoming features update 2021-09-16 08:44:39 -05:00
25 changed files with 335 additions and 138 deletions
-1
View File
@@ -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
+12 -4
View File
@@ -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,8 +39,9 @@
<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 v-if="userCanUpdate" :text="`Mark as ${selectedIsRead ? 'Not Read' : 'Read'}`" direction="bottom">
<ui-read-icon-btn :is-read="selectedIsRead" @click="toggleBatchRead" class="mx-1.5" /> <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> </ui-tooltip>
<template v-if="userCanUpdate"> <template v-if="userCanUpdate">
<ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" /> <ui-icon-btn v-show="!processingBatchDelete" icon="edit" bg-color="warning" class="mx-1.5" @click="batchEditClick" />
@@ -95,12 +96,18 @@ export default {
userCanDelete() { userCanDelete() {
return this.$store.getters['user/getUserCanDelete'] return this.$store.getters['user/getUserCanDelete']
}, },
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
},
selectedIsRead() { selectedIsRead() {
// Find an audiobook that is not read, if none then all audiobooks read // Find an audiobook that is not read, if none then all audiobooks read
return !this.selectedAudiobooks.find((ab) => { return !this.selectedAudiobooks.find((ab) => {
var userAb = this.userAudiobooks[ab] var userAb = this.userAudiobooks[ab]
return !userAb || !userAb.isRead return !userAb || !userAb.isRead
}) })
},
processingBatch() {
return this.$store.state.processingBatch
} }
}, },
methods: { methods: {
@@ -124,6 +131,7 @@ export default {
} }
}, },
toggleBatchRead() { toggleBatchRead() {
this.$store.commit('setProcessingBatch', true)
var newIsRead = !this.selectedIsRead var newIsRead = !this.selectedIsRead
var updateProgressPayloads = this.selectedAudiobooks.map((ab) => { var updateProgressPayloads = this.selectedAudiobooks.map((ab) => {
return { return {
+7 -3
View File
@@ -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.`
+1 -1
View File
@@ -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>
+18 -7
View File
@@ -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
} }
} }
} }
+4
View File
@@ -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: {
+4 -1
View File
@@ -1,5 +1,5 @@
<template> <template>
<button class="icon-btn rounded-md border border-gray-600 flex items-center justify-center h-9 w-9 relative" :class="className" @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>
@@ -39,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;
+8 -1
View File
@@ -14,7 +14,8 @@ export default {
direction: { direction: {
type: String, type: String,
default: 'right' default: 'right'
} },
disabled: Boolean
}, },
data() { data() {
return { return {
@@ -25,6 +26,11 @@ export default {
watch: { watch: {
text() { text() {
this.updateText() this.updateText()
},
disabled(newVal) {
if (newVal && this.isShowing) {
this.hideTooltip()
}
} }
}, },
methods: { methods: {
@@ -81,6 +87,7 @@ export default {
tooltip.style.left = left + 'px' tooltip.style.left = left + 'px'
}, },
showTooltip() { showTooltip() {
if (this.disabled) return
if (!this.tooltip) { if (!this.tooltip) {
this.createTooltip() this.createTooltip()
} }
+13 -13
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "1.1.10", "version": "1.1.13",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
+15 -6
View File
@@ -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">
@@ -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 || []
}, },
+2 -6
View File
@@ -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++) {
+4 -1
View File
@@ -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) => {
+3 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "1.1.10", "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": {
+46 -16
View File
@@ -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)
+34 -24
View File
@@ -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
View File
@@ -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)
+11 -3
View File
@@ -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
} }
} }
+1 -1
View File
@@ -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]
+2 -2
View File
@@ -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()
+7 -1
View File
@@ -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) {
+13 -2
View File
@@ -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
View File
@@ -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/..