Clean and parse author name from directory, sort by author last name, scan for covers

This commit is contained in:
advplyr 2021-08-24 20:24:40 -05:00
parent 759be593b6
commit 51357195e2
28 changed files with 783 additions and 59 deletions

View File

@ -65,4 +65,8 @@
.icon-text { .icon-text {
font-size: 1.1rem; font-size: 1.1rem;
}
#ab-page-wrapper {
background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f);
} }

View File

@ -3,9 +3,9 @@
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8"> <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
<p class="font-book">{{ numShowing }} Audiobooks</p> <p class="font-book">{{ numShowing }} Audiobooks</p>
<div class="flex-grow" /> <div class="flex-grow" />
<controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" /> <controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" />
<span class="px-4 text-sm">by</span> <span class="px-4 text-sm">by</span>
<controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" /> <controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" />
</div> </div>
</div> </div>
</template> </template>

View File

@ -2,7 +2,7 @@
<nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4"> <nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4">
<div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false"> <div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false">
<div class="w-full relative" :style="{ height: width * 1.6 + 'px' }"> <div class="w-full relative" :style="{ height: width * 1.6 + 'px' }">
<cards-book-cover :audiobook="audiobook" /> <cards-book-cover :audiobook="audiobook" :author-override="authorFormat" />
<div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> <div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40">
<div class="h-full flex items-center justify-center"> <div class="h-full flex items-center justify-center">
@ -14,7 +14,6 @@
<span class="material-icons" style="font-size: 16px">edit</span> <span class="material-icons" style="font-size: 16px">edit</span>
</div> </div>
</div> </div>
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> <div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
</div> </div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0"> <ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
@ -62,6 +61,25 @@ export default {
author() { author() {
return this.book.author return this.book.author
}, },
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
authorFormat() {
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
},
volumeNumber() {
return this.book.volumeNumber || null
},
orderBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
userProgressPercent() { userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0 return this.userProgress ? this.userProgress.progress || 0 : 0
}, },

View File

@ -8,6 +8,7 @@
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p> <p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div> </div>
</div> </div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }"> <div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div> <div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p> <p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
@ -26,6 +27,7 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
authorOverride: String,
width: { width: {
type: Number, type: Number,
default: 120 default: 120
@ -36,6 +38,11 @@ export default {
imageFailed: false imageFailed: false
} }
}, },
watch: {
cover() {
this.imageFailed = false
}
},
computed: { computed: {
book() { book() {
return this.audiobook.book || {} return this.audiobook.book || {}
@ -50,6 +57,7 @@ export default {
return this.title return this.title
}, },
author() { author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown' return this.book.author || 'Unknown'
}, },
authorCleaned() { authorCleaned() {

View File

@ -115,6 +115,9 @@ export default {
if (!_sel) return '' if (!_sel) return ''
return _sel.text return _sel.text
}, },
authors() {
return this.$store.getters['audiobooks/getUniqueAuthors']
},
genres() { genres() {
return this.$store.state.audiobooks.genres return this.$store.state.audiobooks.genres
}, },

View File

@ -11,7 +11,7 @@
<template v-for="item in items"> <template v-for="item in items">
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
<div class="flex items-center"> <div class="flex items-center">
<span class="font-normal ml-3 block truncate">{{ item.text }}</span> <span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
</div> </div>
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
@ -37,8 +37,12 @@ export default {
value: 'book.title' value: 'book.title'
}, },
{ {
text: 'Author', text: 'Author (First Last)',
value: 'book.author' value: 'book.authorFL'
},
{
text: 'Author (Last, First)',
value: 'book.authorLF'
}, },
{ {
text: 'Added At', text: 'Added At',
@ -73,7 +77,8 @@ export default {
} }
}, },
selectedText() { selectedText() {
var _sel = this.items.find((i) => i.value === this.selected) var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected
var _sel = this.items.find((i) => i.value === _selected)
if (!_sel) return '' if (!_sel) return ''
return _sel.text return _sel.text
} }

View File

@ -21,9 +21,14 @@
</div> </div>
</div> </div>
<!-- <ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" /> --> <div class="flex mt-2 -mx-1">
<div class="w-3/4 px-1">
<ui-input-dropdown v-model="details.series" label="Series" class="mt-2" :items="series" /> <ui-input-dropdown v-model="details.series" label="Series" :items="series" />
</div>
<div class="flex-grow px-1">
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
</div>
</div>
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
@ -61,6 +66,7 @@ export default {
description: null, description: null,
author: null, author: null,
series: null, series: null,
volumeNumber: null,
publishYear: null, publishYear: null,
genres: [] genres: []
}, },
@ -132,6 +138,7 @@ export default {
this.details.author = this.book.author this.details.author = this.book.author
this.details.genres = this.book.genres || [] this.details.genres = this.book.genres || []
this.details.series = this.book.series this.details.series = this.book.series
this.details.volumeNumber = this.book.volumeNumber
this.details.publishYear = this.book.publishYear this.details.publishYear = this.book.publishYear
this.newTags = this.audiobook.tags || [] this.newTags = this.audiobook.tags || []

View File

@ -3,12 +3,12 @@
<p class="px-1 text-sm font-semibold">{{ label }}</p> <p class="px-1 text-sm font-semibold">{{ label }}</p>
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text"> <div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded px-2 py-2">
<input ref="input" v-model="textInput" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> <input ref="input" v-model="textInput" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div> </div>
</form> </form>
<ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> <ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in itemsToShow"> <template v-for="item in itemsToShow">
<li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> <li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
<div class="flex items-center"> <div class="flex items-center">

View File

@ -4,7 +4,12 @@
<div ref="wrapper" class="relative"> <div ref="wrapper" class="relative">
<form @submit.prevent="submitForm"> <form @submit.prevent="submitForm">
<div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ $snakeToNormal(item) }}</div> <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative">
<div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer">
<span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
</div>
{{ $snakeToNormal(item) }}
</div>
<input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> <input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
</div> </div>
</form> </form>
@ -156,6 +161,13 @@ export default {
} }
this.focus() this.focus()
}, },
removeItem(item) {
var remaining = this.selected.filter((i) => i !== item)
this.$emit('input', remaining)
this.$nextTick(() => {
this.recalcMenuPos()
})
},
insertNewItem(item) { insertNewItem(item) {
var kebabItem = this.$normalToSnake(item) var kebabItem = this.$normalToSnake(item)
this.selected.push(kebabItem) this.selected.push(kebabItem)

View File

@ -1,25 +1,47 @@
<template> <template>
<div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg"> <div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg">
<div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning"> <div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning">
<p class="text-lg font-sans" v-html="text" /> <p class="text-lg font-sans" v-html="text" />
</div> </div>
<div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2">
<ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn>
</div>
</div> </div>
</template> </template>
<script> <script>
export default { export default {
data() { data() {
return {} return {
hasCanceled: false
}
},
watch: {
isScanning(newVal) {
if (newVal) {
this.hasCanceled = false
}
}
}, },
computed: { computed: {
text() { text() {
return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>` var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...'
return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>`
}, },
isScanning() { isScanning() {
return this.isScanningFiles || this.isScanningCovers
},
isScanningFiles() {
return this.$store.state.isScanning return this.$store.state.isScanning
}, },
isScanningCovers() {
return this.$store.state.isScanningCovers
},
scanProgressKey() {
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
},
scanProgress() { scanProgress() {
return this.$store.state.scanProgress return this.$store.state[this.scanProgressKey]
}, },
scanPercent() { scanPercent() {
return this.scanProgress ? this.scanProgress.progress + '%' : '0%' return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
@ -31,7 +53,12 @@ export default {
return this.scanProgress ? this.scanProgress.total : 0 return this.scanProgress ? this.scanProgress.total : 0
} }
}, },
methods: {}, methods: {
cancelScan() {
this.hasCanceled = true
this.$root.socket.emit('cancel_scan')
}
},
mounted() {} mounted() {}
} }
</script> </script>

View File

@ -83,21 +83,37 @@ export default {
} }
this.$store.commit('audiobooks/remove', audiobook) this.$store.commit('audiobooks/remove', audiobook)
}, },
scanComplete(results) { scanComplete({ scanType, results }) {
if (!results) results = {} if (scanType === 'covers') {
this.$store.commit('setIsScanning', false) this.$store.commit('setIsScanningCovers', false)
var scanResultMsgs = [] if (results) {
if (results.added) scanResultMsgs.push(`${results.added} added`) this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`) }
if (results.removed) scanResultMsgs.push(`${results.removed} removed`) } else {
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date') this.$store.commit('setIsScanning', false)
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n')) if (results) {
var scanResultMsgs = []
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date')
else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n'))
}
}
}, },
scanStart() { scanStart(scanType) {
this.$store.commit('setIsScanning', true) if (scanType === 'covers') {
this.$store.commit('setIsScanningCovers', true)
} else {
this.$store.commit('setIsScanning', true)
}
}, },
scanProgress(progress) { scanProgress({ scanType, progress }) {
this.$store.commit('setScanProgress', progress) if (scanType === 'covers') {
this.$store.commit('setCoverScanProgress', progress)
} else {
this.$store.commit('setScanProgress', progress)
}
}, },
userUpdated(user) { userUpdated(user) {
if (this.$store.state.user.user.id === user.id) { if (this.$store.state.user.user.id === user.id) {

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf-client", "name": "audiobookshelf-client",
"version": "0.9.72-beta", "version": "0.9.73-beta",
"description": "Audiobook manager and player", "description": "Audiobook manager and player",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''"> <div id="ab-page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''">
<div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center"> <div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''"> <div id="ab-page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto p-8"> <div class="w-full h-full overflow-y-auto p-8">
<div class="flex max-w-6xl mx-auto"> <div class="flex max-w-6xl mx-auto">
<div class="w-52" style="min-width: 208px"> <div class="w-52" style="min-width: 208px">
@ -10,7 +10,11 @@
</div> </div>
<div class="flex-grow px-10"> <div class="flex-grow px-10">
<div class="flex"> <div class="flex">
<h1 class="text-2xl">{{ title }}</h1> <div class="mb-2">
<h1 class="text-2xl font-book leading-7">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-100 leading-7">by {{ author }}</p>
</div>
<div class="flex-grow" /> <div class="flex-grow" />
</div> </div>
<p class="text-gray-300 text-sm my-1"> <p class="text-gray-300 text-sm my-1">
@ -133,6 +137,17 @@ export default {
author() { author() {
return this.book.author || 'Unknown' return this.book.author || 'Unknown'
}, },
series() {
return this.book.series || null
},
volumeNumber() {
return this.book.volumeNumber || null
},
seriesText() {
if (!this.series) return ''
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
durationPretty() { durationPretty() {
return this.audiobook.durationPretty return this.audiobook.durationPretty
}, },

View File

@ -26,10 +26,15 @@
</table> </table>
</div> </div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
<div class="flex items-center py-4 mb-8"> <div class="py-4 mb-8">
<p class="text-2xl">Scanner</p> <div class="flex items-start py-2">
<div class="flex-grow" /> <p class="text-2xl">Scanner</p>
<ui-btn color="success" @click="scan">Scan</ui-btn> <div class="flex-grow" />
<div class="w-40 flex flex-col">
<ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn>
<ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn>
</div>
</div>
</div> </div>
<div class="h-0.5 bg-primary bg-opacity-50 w-full" /> <div class="h-0.5 bg-primary bg-opacity-50 w-full" />
@ -68,6 +73,12 @@ export default {
computed: { computed: {
streamAudiobook() { streamAudiobook() {
return this.$store.state.streamAudiobook return this.$store.state.streamAudiobook
},
isScanning() {
return this.$store.state.isScanning
},
isScanningCovers() {
return this.$store.state.isScanningCovers
} }
}, },
methods: { methods: {
@ -79,6 +90,9 @@ export default {
scan() { scan() {
this.$root.socket.emit('scan') this.$root.socket.emit('scan')
}, },
scanCovers() {
this.$root.socket.emit('scan_covers')
},
clickAddUser() { clickAddUser() {
this.$toast.info('Under Construction: User management coming soon.') this.$toast.info('Under Construction: User management coming soon.')
}, },

View File

@ -109,6 +109,21 @@ Vue.prototype.$codeToString = (code) => {
return finalform return finalform
} }
function cleanString(str, availableChars) {
var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
var cleaned = ''
for (let i = 0; i < _str.length; i++) {
cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
}
return cleaned
}
export const cleanFilterString = (str) => {
var _str = str.toLowerCase().replace(/ /g, '_')
_str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
return _str
}
function loadImageBlob(uri) { function loadImageBlob(uri) {
return new Promise((resolve) => { return new Promise((resolve) => {
const img = document.createElement('img') const img = document.createElement('img')

View File

@ -1,4 +1,5 @@
import { sort } from '@/assets/fastSort' import { sort } from '@/assets/fastSort'
import { cleanFilterString } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
@ -16,13 +17,14 @@ export const getters = {
var settings = rootState.user.settings || {} var settings = rootState.user.settings || {}
var filterBy = settings.filterBy || '' var filterBy = settings.filterBy || ''
var searchGroups = ['genres', 'tags', 'series'] var searchGroups = ['genres', 'tags', 'series', 'authors']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) { if (group) {
var filter = filterBy.replace(`${group}.`, '') var filter = filterBy.replace(`${group}.`, '')
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
} }
return filtered return filtered
}, },
@ -35,6 +37,10 @@ export const getters = {
// 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) return settings.orderBy.split('.').reduce((a, b) => a[b], ab)
}) })
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)]
} }
} }

View File

@ -5,7 +5,9 @@ export const state = () => ({
selectedAudiobook: null, selectedAudiobook: null,
playOnLoad: false, playOnLoad: false,
isScanning: false, isScanning: false,
isScanningCovers: false,
scanProgress: null, scanProgress: null,
coverScanProgress: null,
developerMode: false developerMode: false
}) })
@ -41,9 +43,16 @@ export const mutations = {
setIsScanning(state, isScanning) { setIsScanning(state, isScanning) {
state.isScanning = isScanning state.isScanning = isScanning
}, },
setScanProgress(state, progress) { setScanProgress(state, scanProgress) {
if (progress > 0) state.isScanning = true if (scanProgress && scanProgress.progress > 0) state.isScanning = true
state.scanProgress = progress state.scanProgress = scanProgress
},
setIsScanningCovers(state, isScanningCovers) {
state.isScanningCovers = isScanningCovers
},
setCoverScanProgress(state, coverScanProgress) {
if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true
state.coverScanProgress = coverScanProgress
}, },
setDeveloperMode(state, val) { setDeveloperMode(state, val) {
state.developerMode = val state.developerMode = val

View File

@ -16,12 +16,11 @@ module.exports = {
}, },
colors: { colors: {
bg: '#373838', bg: '#373838',
primary: '#262626', primary: '#232323',
accent: '#1ad691', accent: '#1ad691',
error: '#FF5252', error: '#FF5252',
info: '#2196F3', info: '#2196F3',
success: '#4CAF50', success: '#4CAF50',
successDark: '#3b8a3e',
warning: '#FB8C00', warning: '#FB8C00',
'black-50': '#bbbbbb', 'black-50': '#bbbbbb',
'black-100': '#666666', 'black-100': '#666666',

View File

@ -1,6 +1,6 @@
{ {
"name": "audiobookshelf", "name": "audiobookshelf",
"version": "0.9.72-beta", "version": "0.9.73-beta",
"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": {
@ -26,4 +26,4 @@
"socket.io": "^4.1.3" "socket.io": "^4.1.3"
}, },
"devDependencies": {} "devDependencies": {}
} }

View File

@ -62,6 +62,10 @@ class Audiobook {
return this.book ? this.book.author : 'Unknown' return this.book ? this.book.author : 'Unknown'
} }
get authorLF() {
return this.book ? this.book.authorLF : null
}
get genres() { get genres() {
return this.book ? this.book.genres || [] : [] return this.book ? this.book.genres || [] : []
} }
@ -136,9 +140,9 @@ class Audiobook {
toJSONExpanded() { toJSONExpanded() {
return { return {
id: this.id, id: this.id,
title: this.title, // title: this.title,
author: this.author, // author: this.author,
cover: this.cover, // cover: this.cover,
path: this.path, path: this.path,
fullPath: this.fullPath, fullPath: this.fullPath,
addedAt: this.addedAt, addedAt: this.addedAt,
@ -306,6 +310,10 @@ class Audiobook {
return hasUpdates return hasUpdates
} }
syncAuthorNames(audiobookData) {
return this.book.syncAuthorNames(audiobookData.authorFL, audiobookData.authorLF)
}
isSearchMatch(search) { isSearchMatch(search) {
return this.book.isSearchMatch(search.toLowerCase().trim()) return this.book.isSearchMatch(search.toLowerCase().trim())
} }

View File

@ -4,7 +4,10 @@ class Book {
this.olid = null this.olid = null
this.title = null this.title = null
this.author = null this.author = null
this.authorFL = null
this.authorLF = null
this.series = null this.series = null
this.volumeNumber = null
this.publishYear = null this.publishYear = null
this.publisher = null this.publisher = null
this.description = null this.description = null
@ -24,7 +27,10 @@ class Book {
this.olid = book.olid this.olid = book.olid
this.title = book.title this.title = book.title
this.author = book.author this.author = book.author
this.authorFL = book.authorFL || null
this.authorLF = book.authorLF || null
this.series = book.series this.series = book.series
this.volumeNumber = book.volumeNumber || null
this.publishYear = book.publishYear this.publishYear = book.publishYear
this.publisher = book.publisher this.publisher = book.publisher
this.description = book.description this.description = book.description
@ -37,7 +43,10 @@ class Book {
olid: this.olid, olid: this.olid,
title: this.title, title: this.title,
author: this.author, author: this.author,
authorFL: this.authorFL,
authorLF: this.authorLF,
series: this.series, series: this.series,
volumeNumber: this.volumeNumber,
publishYear: this.publishYear, publishYear: this.publishYear,
publisher: this.publisher, publisher: this.publisher,
description: this.description, description: this.description,
@ -50,7 +59,10 @@ class Book {
this.olid = data.olid || null this.olid = data.olid || null
this.title = data.title || null this.title = data.title || null
this.author = data.author || null this.author = data.author || null
this.authorLF = data.authorLF || null
this.authorFL = data.authorFL || null
this.series = data.series || null this.series = data.series || null
this.volumeNumber = data.volumeNumber || null
this.publishYear = data.publishYear || null this.publishYear = data.publishYear || null
this.description = data.description || null this.description = data.description || null
this.cover = data.cover || null this.cover = data.cover || null
@ -83,7 +95,20 @@ class Book {
hasUpdates = true hasUpdates = true
} }
} }
return true return hasUpdates
}
syncAuthorNames(authorFL, authorLF) {
var hasUpdates = false
if (authorFL !== this.authorFL) {
this.authorFL = authorFL
hasUpdates = true
}
if (authorLF !== this.authorLF) {
this.authorLF = authorLF
hasUpdates = true
}
return hasUpdates
} }
isSearchMatch(search) { isSearchMatch(search) {

View File

@ -26,7 +26,17 @@ class BookFinder {
return title return title
} }
replaceAccentedChars(str) {
try {
return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
} catch (error) {
Logger.error('[BookFinder] str normalize error', error)
return str
}
}
cleanTitleForCompares(title) { cleanTitleForCompares(title) {
if (!title) return ''
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book") // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
var stripped = this.stripSubtitle(title) var stripped = this.stripSubtitle(title)
@ -35,16 +45,34 @@ class BookFinder {
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game") // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
cleaned = cleaned.replace(/'/g, '') cleaned = cleaned.replace(/'/g, '')
cleaned = this.replaceAccentedChars(cleaned)
return cleaned.toLowerCase()
}
cleanAuthorForCompares(author) {
if (!author) return ''
var cleaned = this.replaceAccentedChars(author)
return cleaned.toLowerCase() return cleaned.toLowerCase()
} }
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
var searchTitle = this.cleanTitleForCompares(title) var searchTitle = this.cleanTitleForCompares(title)
var searchAuthor = this.cleanAuthorForCompares(author)
return books.map(b => { return books.map(b => {
b.cleanedTitle = this.cleanTitleForCompares(b.title) b.cleanedTitle = this.cleanTitleForCompares(b.title)
b.titleDistance = levenshteinDistance(b.cleanedTitle, title) b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
if (author) { if (author) {
b.authorDistance = levenshteinDistance(b.author || '', author) if (!b.author) {
b.authorDistance = author.length
} else {
b.cleanedAuthor = this.cleanAuthorForCompares(b.author)
var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor)
var authorDistance = levenshteinDistance(b.author || '', author)
// Use best distance
if (cleanedAuthorDistance > authorDistance) b.authorDistance = authorDistance
else b.authorDistance = cleanedAuthorDistance
}
} }
b.totalDistance = b.titleDistance + (b.authorDistance || 0) b.totalDistance = b.titleDistance + (b.authorDistance || 0)
b.totalPossibleDistance = b.title.length b.totalPossibleDistance = b.title.length
@ -142,7 +170,8 @@ class BookFinder {
async findCovers(provider, title, author, options = {}) { async findCovers(provider, title, author, options = {}) {
var searchResults = await this.search(provider, title, author, options) var searchResults = await this.search(provider, title, author, options)
console.log('Find Covers search results', searchResults) Logger.info(`[BookFinder] FindCovers search results: ${searchResults.length}`)
var covers = [] var covers = []
searchResults.forEach((result) => { searchResults.forEach((result) => {
if (result.covers && result.covers.length) { if (result.covers && result.covers.length) {

View File

@ -13,6 +13,8 @@ class Scanner {
this.db = db this.db = db
this.emitter = emitter this.emitter = emitter
this.cancelScan = false
this.bookFinder = new BookFinder() this.bookFinder = new BookFinder()
} }
@ -34,6 +36,11 @@ class Scanner {
const scanStart = Date.now() const scanStart = Date.now()
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath) var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
if (this.cancelScan) {
this.cancelScan = false
return null
}
var scanResults = { var scanResults = {
removed: 0, removed: 0,
updated: 0, updated: 0,
@ -54,6 +61,10 @@ class Scanner {
scanResults.removed++ scanResults.removed++
this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified()) this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified())
} }
if (this.cancelScan) {
this.cancelScan = false
return null
}
} }
for (let i = 0; i < audiobookDataFound.length; i++) { for (let i = 0; i < audiobookDataFound.length; i++) {
@ -109,6 +120,11 @@ class Scanner {
hasUpdates = true hasUpdates = true
} }
if (audiobookData.author && existingAudiobook.syncAuthorNames(audiobookData)) {
Logger.info(`[Scanner] "${existingAudiobook.title}" author names updated, "${existingAudiobook.authorLF}"`)
hasUpdates = true
}
if (hasUpdates) { if (hasUpdates) {
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`) Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
existingAudiobook.lastUpdate = Date.now() existingAudiobook.lastUpdate = Date.now()
@ -138,10 +154,17 @@ class Scanner {
} }
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
this.emitter('scan_progress', { this.emitter('scan_progress', {
total: audiobookDataFound.length, scanType: 'files',
done: i + 1, progress: {
progress total: audiobookDataFound.length,
done: i + 1,
progress
}
}) })
if (this.cancelScan) {
this.cancelScan = false
break
}
} }
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 | elapsed: ${secondsToTimestamp(scanElapsed)}`)
@ -161,6 +184,47 @@ class Scanner {
return scanResult return scanResult
} }
async scanCovers() {
var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author)
var found = 0
var notFound = 0
for (let i = 0; i < audiobooksNeedingCover.length; i++) {
var audiobook = audiobooksNeedingCover[i]
var options = {
titleDistance: 2,
authorDistance: 2
}
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options)
if (results.length) {
Logger.info(`[Scanner] Found best cover for "${audiobook.title}"`)
audiobook.book.cover = results[0]
await this.db.updateAudiobook(audiobook)
found++
} else {
notFound++
}
var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length)
this.emitter('scan_progress', {
scanType: 'covers',
progress: {
total: audiobooksNeedingCover.length,
done: i + 1,
progress
}
})
if (this.cancelScan) {
this.cancelScan = false
break
}
}
return {
found,
notFound
}
}
async find(req, res) { async find(req, res) {
var method = req.params.method var method = req.params.method
var query = req.query var query = req.query

View File

@ -42,6 +42,7 @@ class Server {
this.clients = {} this.clients = {}
this.isScanning = false this.isScanning = false
this.isScanningCovers = false
this.isInitialized = false this.isInitialized = false
} }
@ -64,13 +65,28 @@ class Server {
Logger.info('[Server] Starting Scan') Logger.info('[Server] Starting Scan')
this.isScanning = true this.isScanning = true
this.isInitialized = true this.isInitialized = true
this.emitter('scan_start') this.emitter('scan_start', 'files')
var results = await this.scanner.scan() var results = await this.scanner.scan()
this.isScanning = false this.isScanning = false
this.emitter('scan_complete', results) this.emitter('scan_complete', { scanType: 'files', results })
Logger.info('[Server] Scan complete') Logger.info('[Server] Scan complete')
} }
async scanCovers() {
Logger.info('[Server] Start cover scan')
this.isScanningCovers = true
this.emitter('scan_start', 'covers')
var results = await this.scanner.scanCovers()
this.isScanningCovers = false
this.emitter('scan_complete', { scanType: 'covers', results })
Logger.info('[Server] Cover scan complete')
}
cancelScan() {
if (!this.isScanningCovers && !this.isScanning) return
this.scanner.cancelScan = true
}
async init() { async init() {
Logger.info('[Server] Init') Logger.info('[Server] Init')
await this.streamManager.removeOrphanStreams() await this.streamManager.removeOrphanStreams()
@ -149,6 +165,8 @@ class Server {
socket.on('auth', (token) => this.authenticateSocket(socket, token)) socket.on('auth', (token) => this.authenticateSocket(socket, token))
socket.on('scan', this.scan.bind(this)) socket.on('scan', this.scan.bind(this))
socket.on('scan_covers', this.scanCovers.bind(this))
socket.on('cancel_scan', this.cancelScan.bind(this))
socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))

View File

@ -0,0 +1,67 @@
const parseFullName = require('./parseFullName')
function parseName(name) {
var parts = parseFullName(name)
var firstName = parts.first
if (firstName && parts.middle) firstName += ' ' + parts.middle
return {
first_name: firstName,
last_name: parts.last
}
}
// Check if this name segment is of the format "Last, First" or "First Last"
// return true is "Last, First"
function checkIsALastName(name) {
if (!name.includes(' ')) return true // No spaces must be a Last name
var parsed = parseFullName(name)
if (!parsed.first) return true // had spaces but not a first name i.e. "von Mises", must be last name only
return false
}
module.exports = (author) => {
if (!author) return null
var splitByComma = author.split(', ')
var authors = []
// 1 author FIRST LAST
if (splitByComma.length === 1) {
authors.push(parseName(author))
} else {
var firstChunkIsALastName = checkIsALastName(splitByComma[0])
var isEvenNum = splitByComma.length % 2 === 0
if (!isEvenNum && firstChunkIsALastName) {
// console.error('Multi-author LAST,FIRST entry has a straggler (could be roman numerals or a suffix), ignore it', splitByComma[splitByComma.length - 1])
splitByComma = splitByComma.slice(0, splitByComma.length - 1)
}
if (firstChunkIsALastName) {
var numAuthors = splitByComma.length / 2
for (let i = 0; i < numAuthors; i++) {
var last = splitByComma.shift()
var first = splitByComma.shift()
authors.push({
first_name: first,
last_name: last
})
}
} else {
splitByComma.forEach((segment) => {
authors.push(parseName(segment))
})
}
}
var firstLast = authors.length ? authors.map(a => a.first_name ? `${a.first_name} ${a.last_name}` : a.last_name).join(', ') : ''
var lastFirst = authors.length ? authors.map(a => a.first_name ? `${a.last_name}, ${a.first_name}` : a.last_name).join(', ') : ''
return {
authorFL: firstLast,
authorLF: lastFirst,
authorsParsed: authors
}
}

View File

@ -0,0 +1,346 @@
// https://github.com/RateGravity/parse-full-name/blob/master/index.js
module.exports = (nameToParse, partToReturn, fixCase, stopOnError, useLongLists) => {
var i, j, k, l, m, n, part, comma, titleList, suffixList, prefixList, regex,
partToCheck, partFound, partsFoundCount, firstComma, remainingCommas,
nameParts = [], nameCommas = [null], partsFound = [],
conjunctionList = ['&', 'and', 'et', 'e', 'of', 'the', 'und', 'y'],
parsedName = {
title: '', first: '', middle: '', last: '', nick: '', suffix: '', error: []
};
// Validate inputs, or set to defaults
partToReturn = partToReturn && ['title', 'first', 'middle', 'last', 'nick',
'suffix', 'error'].indexOf(partToReturn.toLowerCase()) > -1 ?
partToReturn.toLowerCase() : 'all';
// 'all' = return object with all parts, others return single part
if (fixCase === false) fixCase = 0;
if (fixCase === true) fixCase = 1;
fixCase = fixCase !== 'undefined' && (fixCase === 0 || fixCase === 1) ?
fixCase : -1; // -1 = fix case only if input is all upper or lowercase
if (stopOnError === true) stopOnError = 1;
stopOnError = stopOnError && stopOnError === 1 ? 1 : 0;
// false = output warnings on parse error, but don't stop
if (useLongLists === true) useLongLists = 1;
useLongLists = useLongLists && useLongLists === 1 ? 1 : 0; // 0 = short lists
// If stopOnError = 1, throw error, otherwise return error messages in array
function handleError(errorMessage) {
if (stopOnError) {
throw 'Error: ' + errorMessage;
} else {
parsedName.error.push('Error: ' + errorMessage);
}
}
// If fixCase = 1, fix case of parsedName parts before returning
function fixParsedNameCase(fixedCaseName, fixCaseNow) {
var forceCaseList = ['e', 'y', 'av', 'af', 'da', 'dal', 'de', 'del', 'der', 'di',
'la', 'le', 'van', 'der', 'den', 'vel', 'von', 'II', 'III', 'IV', 'J.D.', 'LL.M.',
'M.D.', 'D.O.', 'D.C.', 'Ph.D.'];
var forceCaseListIndex;
var namePartLabels = [];
var namePartWords;
if (fixCaseNow) {
namePartLabels = Object.keys(parsedName)
.filter(function (v) { return v !== 'error'; });
for (i = 0, l = namePartLabels.length; i < l; i++) {
if (fixedCaseName[namePartLabels[i]]) {
namePartWords = (fixedCaseName[namePartLabels[i]] + '').split(' ');
for (j = 0, m = namePartWords.length; j < m; j++) {
forceCaseListIndex = forceCaseList
.map(function (v) { return v.toLowerCase(); })
.indexOf(namePartWords[j].toLowerCase());
if (forceCaseListIndex > -1) { // Set case of words in forceCaseList
namePartWords[j] = forceCaseList[forceCaseListIndex];
} else if (namePartWords[j].length === 1) { // Uppercase initials
namePartWords[j] = namePartWords[j].toUpperCase();
} else if (
namePartWords[j].length > 2 &&
namePartWords[j].slice(0, 1) ===
namePartWords[j].slice(0, 1).toUpperCase() &&
namePartWords[j].slice(1, 2) ===
namePartWords[j].slice(1, 2).toLowerCase() &&
namePartWords[j].slice(2) ===
namePartWords[j].slice(2).toUpperCase()
) { // Detect McCASE and convert to McCase
namePartWords[j] = namePartWords[j].slice(0, 3) +
namePartWords[j].slice(3).toLowerCase();
} else if (
namePartLabels[j] === 'suffix' &&
nameParts[j].slice(-1) !== '.' &&
!suffixList.indexOf(nameParts[j].toLowerCase())
) { // Convert suffix abbreviations to UPPER CASE
if (namePartWords[j] === namePartWords[j].toLowerCase()) {
namePartWords[j] = namePartWords[j].toUpperCase();
}
} else { // Convert to Title Case
namePartWords[j] = namePartWords[j].slice(0, 1).toUpperCase() +
namePartWords[j].slice(1).toLowerCase();
}
}
fixedCaseName[namePartLabels[i]] = namePartWords.join(' ');
}
}
}
return fixedCaseName;
}
// If no input name, or input name is not a string, abort
if (!nameToParse || typeof nameToParse !== 'string') {
handleError('No input');
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
} else {
nameToParse = nameToParse.trim();
}
// Auto-detect fixCase: fix if nameToParse is all upper or all lowercase
if (fixCase === -1) {
fixCase = (
nameToParse === nameToParse.toUpperCase() ||
nameToParse === nameToParse.toLowerCase() ? 1 : 0
);
}
// Initilize lists of prefixs, suffixs, and titles to detect
// Note: These list entries must be all lowercase
if (useLongLists) {
suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
'v', 'clu', 'chfc', 'cfp', 'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.',
'p.c.', 'ph.d.'];
prefixList = ['a', 'ab', 'antune', 'ap', 'abu', 'al', 'alm', 'alt', 'bab', 'bäck',
'bar', 'bath', 'bat', 'beau', 'beck', 'ben', 'berg', 'bet', 'bin', 'bint', 'birch',
'björk', 'björn', 'bjur', 'da', 'dahl', 'dal', 'de', 'degli', 'dele', 'del',
'della', 'der', 'di', 'dos', 'du', 'e', 'ek', 'el', 'escob', 'esch', 'fleisch',
'fitz', 'fors', 'gott', 'griff', 'haj', 'haug', 'holm', 'ibn', 'kauf', 'kil',
'koop', 'kvarn', 'la', 'le', 'lind', 'lönn', 'lund', 'mac', 'mhic', 'mic', 'mir',
'na', 'naka', 'neder', 'nic', 'ni', 'nin', 'nord', 'norr', 'ny', 'o', 'ua', 'ui\'',
'öfver', 'ost', 'över', 'öz', 'papa', 'pour', 'quarn', 'skog', 'skoog', 'sten',
'stor', 'ström', 'söder', 'ter', 'ter', 'tre', 'türk', 'van', 'väst', 'väster',
'vest', 'von'];
titleList = ['mr', 'mrs', 'ms', 'miss', 'dr', 'herr', 'monsieur', 'hr', 'frau',
'a v m', 'admiraal', 'admiral', 'air cdre', 'air commodore', 'air marshal',
'air vice marshal', 'alderman', 'alhaji', 'ambassador', 'baron', 'barones',
'brig', 'brig gen', 'brig general', 'brigadier', 'brigadier general',
'brother', 'canon', 'capt', 'captain', 'cardinal', 'cdr', 'chief', 'cik', 'cmdr',
'coach', 'col', 'col dr', 'colonel', 'commandant', 'commander', 'commissioner',
'commodore', 'comte', 'comtessa', 'congressman', 'conseiller', 'consul',
'conte', 'contessa', 'corporal', 'councillor', 'count', 'countess',
'crown prince', 'crown princess', 'dame', 'datin', 'dato', 'datuk',
'datuk seri', 'deacon', 'deaconess', 'dean', 'dhr', 'dipl ing', 'doctor',
'dott', 'dott sa', 'dr', 'dr ing', 'dra', 'drs', 'embajador', 'embajadora', 'en',
'encik', 'eng', 'eur ing', 'exma sra', 'exmo sr', 'f o', 'father',
'first lieutient', 'first officer', 'flt lieut', 'flying officer', 'fr',
'frau', 'fraulein', 'fru', 'gen', 'generaal', 'general', 'governor', 'graaf',
'gravin', 'group captain', 'grp capt', 'h e dr', 'h h', 'h m', 'h r h', 'hajah',
'haji', 'hajim', 'her highness', 'her majesty', 'herr', 'high chief',
'his highness', 'his holiness', 'his majesty', 'hon', 'hr', 'hra', 'ing', 'ir',
'jonkheer', 'judge', 'justice', 'khun ying', 'kolonel', 'lady', 'lcda', 'lic',
'lieut', 'lieut cdr', 'lieut col', 'lieut gen', 'lord', 'm', 'm l', 'm r',
'madame', 'mademoiselle', 'maj gen', 'major', 'master', 'mevrouw', 'miss',
'mlle', 'mme', 'monsieur', 'monsignor', 'mr', 'mrs', 'ms', 'mstr', 'nti', 'pastor',
'president', 'prince', 'princess', 'princesse', 'prinses', 'prof', 'prof dr',
'prof sir', 'professor', 'puan', 'puan sri', 'rabbi', 'rear admiral', 'rev',
'rev canon', 'rev dr', 'rev mother', 'reverend', 'rva', 'senator', 'sergeant',
'sheikh', 'sheikha', 'sig', 'sig na', 'sig ra', 'sir', 'sister', 'sqn ldr', 'sr',
'sr d', 'sra', 'srta', 'sultan', 'tan sri', 'tan sri dato', 'tengku', 'teuku',
'than puying', 'the hon dr', 'the hon justice', 'the hon miss', 'the hon mr',
'the hon mrs', 'the hon ms', 'the hon sir', 'the very rev', 'toh puan', 'tun',
'vice admiral', 'viscount', 'viscountess', 'wg cdr', 'ind', 'misc', 'mx'];
} else {
suffixList = ['esq', 'esquire', 'jr', 'jnr', 'sr', 'snr', '2', 'ii', 'iii', 'iv',
'md', 'phd', 'j.d.', 'll.m.', 'm.d.', 'd.o.', 'd.c.', 'p.c.', 'ph.d.'];
prefixList = ['ab', 'bar', 'bin', 'da', 'dal', 'de', 'de la', 'del', 'della', 'der',
'di', 'du', 'ibn', 'l\'', 'la', 'le', 'san', 'st', 'st.', 'ste', 'ter', 'van',
'van de', 'van der', 'van den', 'vel', 'ver', 'vere', 'von'];
titleList = ['dr', 'miss', 'mr', 'mrs', 'ms', 'prof', 'sir', 'frau', 'herr', 'hr',
'monsieur', 'captain', 'doctor', 'judge', 'officer', 'professor', 'ind', 'misc',
'mx'];
}
// Nickname: remove and store parts with surrounding punctuation as nicknames
regex = /\s(?:[']([^']+)[']|[“”"]([^“”"]+)[“”"]|\[([^\]]+)\]|\(([^\)]+)\)),?\s/g;
partFound = (' ' + nameToParse + ' ').match(regex);
if (partFound) partsFound = partsFound.concat(partFound);
partsFoundCount = partsFound.length;
if (partsFoundCount === 1) {
parsedName.nick = partsFound[0].slice(2).slice(0, -2);
if (parsedName.nick.slice(-1) === ',') {
parsedName.nick = parsedName.nick.slice(0, -1);
}
nameToParse = (' ' + nameToParse + ' ').replace(partsFound[0], ' ').trim();
partsFound = [];
} else if (partsFoundCount > 1) {
handleError(partsFoundCount + ' nicknames found');
for (i = 0; i < partsFoundCount; i++) {
nameToParse = (' ' + nameToParse + ' ')
.replace(partsFound[i], ' ').trim();
partsFound[i] = partsFound[i].slice(2).slice(0, -2);
if (partsFound[i].slice(-1) === ',') {
partsFound[i] = partsFound[i].slice(0, -1);
}
}
parsedName.nick = partsFound.join(', ');
partsFound = [];
}
if (!nameToParse.trim().length) {
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
}
// Split remaining nameToParse into parts, remove and store preceding commas
for (i = 0, n = nameToParse.split(' '), l = n.length; i < l; i++) {
part = n[i];
comma = null;
if (part.slice(-1) === ',') {
comma = ',';
part = part.slice(0, -1);
}
nameParts.push(part);
nameCommas.push(comma);
}
// Suffix: remove and store matching parts as suffixes
for (l = nameParts.length, i = l - 1; i > 0; i--) {
partToCheck = (nameParts[i].slice(-1) === '.' ?
nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
if (
suffixList.indexOf(partToCheck) > -1 ||
suffixList.indexOf(partToCheck + '.') > -1
) {
partsFound = nameParts.splice(i, 1).concat(partsFound);
if (nameCommas[i] === ',') { // Keep comma, either before or after
nameCommas.splice(i + 1, 1);
} else {
nameCommas.splice(i, 1);
}
}
}
partsFoundCount = partsFound.length;
if (partsFoundCount === 1) {
parsedName.suffix = partsFound[0];
partsFound = [];
} else if (partsFoundCount > 1) {
handleError(partsFoundCount + ' suffixes found');
parsedName.suffix = partsFound.join(', ');
partsFound = [];
}
if (!nameParts.length) {
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
}
// Title: remove and store matching parts as titles
for (l = nameParts.length, i = l - 1; i >= 0; i--) {
partToCheck = (nameParts[i].slice(-1) === '.' ?
nameParts[i].slice(0, -1).toLowerCase() : nameParts[i].toLowerCase());
if (
titleList.indexOf(partToCheck) > -1 ||
titleList.indexOf(partToCheck + '.') > -1
) {
partsFound = nameParts.splice(i, 1).concat(partsFound);
if (nameCommas[i] === ',') { // Keep comma, either before or after
nameCommas.splice(i + 1, 1);
} else {
nameCommas.splice(i, 1);
}
}
}
partsFoundCount = partsFound.length;
if (partsFoundCount === 1) {
parsedName.title = partsFound[0];
partsFound = [];
} else if (partsFoundCount > 1) {
handleError(partsFoundCount + ' titles found');
parsedName.title = partsFound.join(', ');
partsFound = [];
}
if (!nameParts.length) {
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
}
// Join name prefixes to following names
if (nameParts.length > 1) {
for (i = nameParts.length - 2; i >= 0; i--) {
if (prefixList.indexOf(nameParts[i].toLowerCase()) > -1) {
nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1];
nameParts.splice(i + 1, 1);
nameCommas.splice(i + 1, 1);
}
}
}
// Join conjunctions to surrounding names
if (nameParts.length > 2) {
for (i = nameParts.length - 3; i >= 0; i--) {
if (conjunctionList.indexOf(nameParts[i + 1].toLowerCase()) > -1) {
nameParts[i] = nameParts[i] + ' ' + nameParts[i + 1] + ' ' + nameParts[i + 2];
nameParts.splice(i + 1, 2);
nameCommas.splice(i + 1, 2);
i--;
}
}
}
// Suffix: remove and store items after extra commas as suffixes
nameCommas.pop();
firstComma = nameCommas.indexOf(',');
remainingCommas = nameCommas.filter(function (v) { return v !== null; }).length;
if (firstComma > 1 || remainingCommas > 1) {
for (i = nameParts.length - 1; i >= 2; i--) {
if (nameCommas[i] === ',') {
partsFound = nameParts.splice(i, 1).concat(partsFound);
nameCommas.splice(i, 1);
remainingCommas--;
} else {
break;
}
}
}
if (partsFound.length) {
if (parsedName.suffix) {
partsFound = [parsedName.suffix].concat(partsFound);
}
parsedName.suffix = partsFound.join(', ');
partsFound = [];
}
// Last name: remove and store last name
if (remainingCommas > 0) {
if (remainingCommas > 1) {
handleError((remainingCommas - 1) + ' extra commas found');
}
// Remove and store all parts before first comma as last name
if (nameCommas.indexOf(',')) {
parsedName.last = nameParts.splice(0, nameCommas.indexOf(',')).join(' ');
nameCommas.splice(0, nameCommas.indexOf(','));
}
} else {
// Remove and store last part as last name
parsedName.last = nameParts.pop();
}
if (!nameParts.length) {
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
}
// First name: remove and store first part as first name
parsedName.first = nameParts.shift();
if (!nameParts.length) {
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
}
// Middle name: store all remaining parts as middle name
if (nameParts.length > 2) {
handleError(nameParts.length + ' middle names');
}
parsedName.middle = nameParts.join(' ');
parsedName = fixParsedNameCase(parsedName, fixCase);
return partToReturn === 'all' ? parsedName : parsedName[partToReturn];
};

View File

@ -1,6 +1,7 @@
const Path = require('path') const Path = require('path')
const dir = require('node-dir') const dir = require('node-dir')
const Logger = require('../Logger') const Logger = require('../Logger')
const parseAuthors = require('./parseAuthors')
const { cleanString } = require('./index') const { cleanString } = require('./index')
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3'] const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
@ -74,6 +75,14 @@ async function getAllAudiobookFiles(abRootPath) {
parts: [], parts: [],
otherFiles: [] otherFiles: []
} }
if (author) {
var parsedAuthors = parseAuthors(author)
if (parsedAuthors) {
var { authorLF, authorFL } = parsedAuthors
audiobooks[path].authorLF = authorLF || null
audiobooks[path].authorFL = authorFL || null
}
}
} }
var filetype = getFileType(pathformat.ext) var filetype = getFileType(pathformat.ext)