mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-30 18:48:55 +01:00
Clean and parse author name from directory, sort by author last name, scan for covers
This commit is contained in:
parent
759be593b6
commit
51357195e2
@ -66,3 +66,7 @@
|
||||
.icon-text {
|
||||
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);
|
||||
}
|
@ -3,9 +3,9 @@
|
||||
<div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8">
|
||||
<p class="font-book">{{ numShowing }} Audiobooks</p>
|
||||
<div class="flex-grow" />
|
||||
<controls-filter-select v-model="settings.filterBy" class="w-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>
|
||||
<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>
|
||||
</template>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<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="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 class="h-full flex items-center justify-center">
|
||||
@ -14,7 +14,6 @@
|
||||
<span class="material-icons" style="font-size: 16px">edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0">
|
||||
@ -62,6 +61,25 @@ export default {
|
||||
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() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
|
@ -8,6 +8,7 @@
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</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>
|
||||
<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,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@ -36,6 +38,11 @@ export default {
|
||||
imageFailed: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
@ -50,6 +57,7 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
},
|
||||
authorCleaned() {
|
||||
|
@ -115,6 +115,9 @@ export default {
|
||||
if (!_sel) return ''
|
||||
return _sel.text
|
||||
},
|
||||
authors() {
|
||||
return this.$store.getters['audiobooks/getUniqueAuthors']
|
||||
},
|
||||
genres() {
|
||||
return this.$store.state.audiobooks.genres
|
||||
},
|
||||
|
@ -11,7 +11,7 @@
|
||||
<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)">
|
||||
<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>
|
||||
<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>
|
||||
@ -37,8 +37,12 @@ export default {
|
||||
value: 'book.title'
|
||||
},
|
||||
{
|
||||
text: 'Author',
|
||||
value: 'book.author'
|
||||
text: 'Author (First Last)',
|
||||
value: 'book.authorFL'
|
||||
},
|
||||
{
|
||||
text: 'Author (Last, First)',
|
||||
value: 'book.authorLF'
|
||||
},
|
||||
{
|
||||
text: 'Added At',
|
||||
@ -73,7 +77,8 @@ export default {
|
||||
}
|
||||
},
|
||||
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 ''
|
||||
return _sel.text
|
||||
}
|
||||
|
@ -21,9 +21,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" /> -->
|
||||
|
||||
<ui-input-dropdown v-model="details.series" label="Series" class="mt-2" :items="series" />
|
||||
<div class="flex mt-2 -mx-1">
|
||||
<div class="w-3/4 px-1">
|
||||
<ui-input-dropdown v-model="details.series" label="Series" :items="series" />
|
||||
</div>
|
||||
<div class="flex-grow px-1">
|
||||
<ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" />
|
||||
|
||||
@ -61,6 +66,7 @@ export default {
|
||||
description: null,
|
||||
author: null,
|
||||
series: null,
|
||||
volumeNumber: null,
|
||||
publishYear: null,
|
||||
genres: []
|
||||
},
|
||||
@ -132,6 +138,7 @@ export default {
|
||||
this.details.author = this.book.author
|
||||
this.details.genres = this.book.genres || []
|
||||
this.details.series = this.book.series
|
||||
this.details.volumeNumber = this.book.volumeNumber
|
||||
this.details.publishYear = this.book.publishYear
|
||||
|
||||
this.newTags = this.audiobook.tags || []
|
||||
|
@ -3,12 +3,12 @@
|
||||
<p class="px-1 text-sm font-semibold">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<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" />
|
||||
</div>
|
||||
</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">
|
||||
<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">
|
||||
|
@ -4,7 +4,12 @@
|
||||
<div ref="wrapper" class="relative">
|
||||
<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 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" />
|
||||
</div>
|
||||
</form>
|
||||
@ -156,6 +161,13 @@ export default {
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
var kebabItem = this.$normalToSnake(item)
|
||||
this.selected.push(kebabItem)
|
||||
|
@ -1,25 +1,47 @@
|
||||
<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">
|
||||
<p class="text-lg font-sans" v-html="text" />
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
hasCanceled: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
isScanning(newVal) {
|
||||
if (newVal) {
|
||||
this.hasCanceled = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
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() {
|
||||
return this.isScanningFiles || this.isScanningCovers
|
||||
},
|
||||
isScanningFiles() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
},
|
||||
scanProgressKey() {
|
||||
return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress'
|
||||
},
|
||||
scanProgress() {
|
||||
return this.$store.state.scanProgress
|
||||
return this.$store.state[this.scanProgressKey]
|
||||
},
|
||||
scanPercent() {
|
||||
return this.scanProgress ? this.scanProgress.progress + '%' : '0%'
|
||||
@ -31,7 +53,12 @@ export default {
|
||||
return this.scanProgress ? this.scanProgress.total : 0
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
cancelScan() {
|
||||
this.hasCanceled = true
|
||||
this.$root.socket.emit('cancel_scan')
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
@ -83,21 +83,37 @@ export default {
|
||||
}
|
||||
this.$store.commit('audiobooks/remove', audiobook)
|
||||
},
|
||||
scanComplete(results) {
|
||||
if (!results) results = {}
|
||||
scanComplete({ scanType, results }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', false)
|
||||
if (results) {
|
||||
this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`)
|
||||
}
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', false)
|
||||
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) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setIsScanningCovers', true)
|
||||
} else {
|
||||
this.$store.commit('setIsScanning', true)
|
||||
}
|
||||
},
|
||||
scanProgress(progress) {
|
||||
scanProgress({ scanType, progress }) {
|
||||
if (scanType === 'covers') {
|
||||
this.$store.commit('setCoverScanProgress', progress)
|
||||
} else {
|
||||
this.$store.commit('setScanProgress', progress)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
if (this.$store.state.user.user.id === user.id) {
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "0.9.72-beta",
|
||||
"version": "0.9.73-beta",
|
||||
"description": "Audiobook manager and player",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
<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="flex max-w-6xl mx-auto">
|
||||
<div class="w-52" style="min-width: 208px">
|
||||
@ -10,7 +10,11 @@
|
||||
</div>
|
||||
<div class="flex-grow px-10">
|
||||
<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>
|
||||
<p class="text-gray-300 text-sm my-1">
|
||||
@ -133,6 +137,17 @@ export default {
|
||||
author() {
|
||||
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() {
|
||||
return this.audiobook.durationPretty
|
||||
},
|
||||
|
@ -26,10 +26,15 @@
|
||||
</table>
|
||||
</div>
|
||||
<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">
|
||||
<div class="flex items-start py-2">
|
||||
<p class="text-2xl">Scanner</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="scan">Scan</ui-btn>
|
||||
<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 class="h-0.5 bg-primary bg-opacity-50 w-full" />
|
||||
@ -68,6 +73,12 @@ export default {
|
||||
computed: {
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
isScanning() {
|
||||
return this.$store.state.isScanning
|
||||
},
|
||||
isScanningCovers() {
|
||||
return this.$store.state.isScanningCovers
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@ -79,6 +90,9 @@ export default {
|
||||
scan() {
|
||||
this.$root.socket.emit('scan')
|
||||
},
|
||||
scanCovers() {
|
||||
this.$root.socket.emit('scan_covers')
|
||||
},
|
||||
clickAddUser() {
|
||||
this.$toast.info('Under Construction: User management coming soon.')
|
||||
},
|
||||
|
@ -109,6 +109,21 @@ Vue.prototype.$codeToString = (code) => {
|
||||
return finalform
|
||||
}
|
||||
|
||||
function cleanString(str, availableChars) {
|
||||
var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
|
||||
var cleaned = ''
|
||||
for (let i = 0; i < _str.length; i++) {
|
||||
cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i]
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
export const cleanFilterString = (str) => {
|
||||
var _str = str.toLowerCase().replace(/ /g, '_')
|
||||
_str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz")
|
||||
return _str
|
||||
}
|
||||
|
||||
function loadImageBlob(uri) {
|
||||
return new Promise((resolve) => {
|
||||
const img = document.createElement('img')
|
||||
|
@ -1,4 +1,5 @@
|
||||
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']
|
||||
|
||||
@ -16,13 +17,14 @@ export const getters = {
|
||||
var settings = rootState.user.settings || {}
|
||||
var filterBy = settings.filterBy || ''
|
||||
|
||||
var searchGroups = ['genres', 'tags', 'series']
|
||||
var searchGroups = ['genres', 'tags', 'series', 'authors']
|
||||
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||
if (group) {
|
||||
var filter = filterBy.replace(`${group}.`, '')
|
||||
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||
else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
|
||||
}
|
||||
return filtered
|
||||
},
|
||||
@ -35,6 +37,10 @@ export const getters = {
|
||||
// Supports dot notation strings i.e. "book.title"
|
||||
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)]
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -5,7 +5,9 @@ export const state = () => ({
|
||||
selectedAudiobook: null,
|
||||
playOnLoad: false,
|
||||
isScanning: false,
|
||||
isScanningCovers: false,
|
||||
scanProgress: null,
|
||||
coverScanProgress: null,
|
||||
developerMode: false
|
||||
})
|
||||
|
||||
@ -41,9 +43,16 @@ export const mutations = {
|
||||
setIsScanning(state, isScanning) {
|
||||
state.isScanning = isScanning
|
||||
},
|
||||
setScanProgress(state, progress) {
|
||||
if (progress > 0) state.isScanning = true
|
||||
state.scanProgress = progress
|
||||
setScanProgress(state, scanProgress) {
|
||||
if (scanProgress && scanProgress.progress > 0) state.isScanning = true
|
||||
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) {
|
||||
state.developerMode = val
|
||||
|
@ -16,12 +16,11 @@ module.exports = {
|
||||
},
|
||||
colors: {
|
||||
bg: '#373838',
|
||||
primary: '#262626',
|
||||
primary: '#232323',
|
||||
accent: '#1ad691',
|
||||
error: '#FF5252',
|
||||
info: '#2196F3',
|
||||
success: '#4CAF50',
|
||||
successDark: '#3b8a3e',
|
||||
warning: '#FB8C00',
|
||||
'black-50': '#bbbbbb',
|
||||
'black-100': '#666666',
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf",
|
||||
"version": "0.9.72-beta",
|
||||
"version": "0.9.73-beta",
|
||||
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
@ -62,6 +62,10 @@ class Audiobook {
|
||||
return this.book ? this.book.author : 'Unknown'
|
||||
}
|
||||
|
||||
get authorLF() {
|
||||
return this.book ? this.book.authorLF : null
|
||||
}
|
||||
|
||||
get genres() {
|
||||
return this.book ? this.book.genres || [] : []
|
||||
}
|
||||
@ -136,9 +140,9 @@ class Audiobook {
|
||||
toJSONExpanded() {
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
cover: this.cover,
|
||||
// title: this.title,
|
||||
// author: this.author,
|
||||
// cover: this.cover,
|
||||
path: this.path,
|
||||
fullPath: this.fullPath,
|
||||
addedAt: this.addedAt,
|
||||
@ -306,6 +310,10 @@ class Audiobook {
|
||||
return hasUpdates
|
||||
}
|
||||
|
||||
syncAuthorNames(audiobookData) {
|
||||
return this.book.syncAuthorNames(audiobookData.authorFL, audiobookData.authorLF)
|
||||
}
|
||||
|
||||
isSearchMatch(search) {
|
||||
return this.book.isSearchMatch(search.toLowerCase().trim())
|
||||
}
|
||||
|
@ -4,7 +4,10 @@ class Book {
|
||||
this.olid = null
|
||||
this.title = null
|
||||
this.author = null
|
||||
this.authorFL = null
|
||||
this.authorLF = null
|
||||
this.series = null
|
||||
this.volumeNumber = null
|
||||
this.publishYear = null
|
||||
this.publisher = null
|
||||
this.description = null
|
||||
@ -24,7 +27,10 @@ class Book {
|
||||
this.olid = book.olid
|
||||
this.title = book.title
|
||||
this.author = book.author
|
||||
this.authorFL = book.authorFL || null
|
||||
this.authorLF = book.authorLF || null
|
||||
this.series = book.series
|
||||
this.volumeNumber = book.volumeNumber || null
|
||||
this.publishYear = book.publishYear
|
||||
this.publisher = book.publisher
|
||||
this.description = book.description
|
||||
@ -37,7 +43,10 @@ class Book {
|
||||
olid: this.olid,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
authorFL: this.authorFL,
|
||||
authorLF: this.authorLF,
|
||||
series: this.series,
|
||||
volumeNumber: this.volumeNumber,
|
||||
publishYear: this.publishYear,
|
||||
publisher: this.publisher,
|
||||
description: this.description,
|
||||
@ -50,7 +59,10 @@ class Book {
|
||||
this.olid = data.olid || null
|
||||
this.title = data.title || null
|
||||
this.author = data.author || null
|
||||
this.authorLF = data.authorLF || null
|
||||
this.authorFL = data.authorFL || null
|
||||
this.series = data.series || null
|
||||
this.volumeNumber = data.volumeNumber || null
|
||||
this.publishYear = data.publishYear || null
|
||||
this.description = data.description || null
|
||||
this.cover = data.cover || null
|
||||
@ -83,7 +95,20 @@ class Book {
|
||||
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) {
|
||||
|
@ -26,7 +26,17 @@ class BookFinder {
|
||||
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) {
|
||||
if (!title) return ''
|
||||
// Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
|
||||
var stripped = this.stripSubtitle(title)
|
||||
|
||||
@ -35,16 +45,34 @@ class BookFinder {
|
||||
|
||||
// Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
|
||||
cleaned = cleaned.replace(/'/g, '')
|
||||
cleaned = this.replaceAccentedChars(cleaned)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
cleanAuthorForCompares(author) {
|
||||
if (!author) return ''
|
||||
var cleaned = this.replaceAccentedChars(author)
|
||||
return cleaned.toLowerCase()
|
||||
}
|
||||
|
||||
filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) {
|
||||
var searchTitle = this.cleanTitleForCompares(title)
|
||||
var searchAuthor = this.cleanAuthorForCompares(author)
|
||||
return books.map(b => {
|
||||
b.cleanedTitle = this.cleanTitleForCompares(b.title)
|
||||
b.titleDistance = levenshteinDistance(b.cleanedTitle, title)
|
||||
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.totalPossibleDistance = b.title.length
|
||||
@ -142,7 +170,8 @@ class BookFinder {
|
||||
|
||||
async findCovers(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 = []
|
||||
searchResults.forEach((result) => {
|
||||
if (result.covers && result.covers.length) {
|
||||
|
@ -13,6 +13,8 @@ class Scanner {
|
||||
this.db = db
|
||||
this.emitter = emitter
|
||||
|
||||
this.cancelScan = false
|
||||
|
||||
this.bookFinder = new BookFinder()
|
||||
}
|
||||
|
||||
@ -34,6 +36,11 @@ class Scanner {
|
||||
const scanStart = Date.now()
|
||||
var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath)
|
||||
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
|
||||
var scanResults = {
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
@ -54,6 +61,10 @@ class Scanner {
|
||||
scanResults.removed++
|
||||
this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified())
|
||||
}
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < audiobookDataFound.length; i++) {
|
||||
@ -109,6 +120,11 @@ class Scanner {
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (audiobookData.author && existingAudiobook.syncAuthorNames(audiobookData)) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" author names updated, "${existingAudiobook.authorLF}"`)
|
||||
hasUpdates = true
|
||||
}
|
||||
|
||||
if (hasUpdates) {
|
||||
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`)
|
||||
existingAudiobook.lastUpdate = Date.now()
|
||||
@ -138,10 +154,17 @@ class Scanner {
|
||||
}
|
||||
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
||||
this.emitter('scan_progress', {
|
||||
scanType: 'files',
|
||||
progress: {
|
||||
total: audiobookDataFound.length,
|
||||
done: i + 1,
|
||||
progress
|
||||
}
|
||||
})
|
||||
if (this.cancelScan) {
|
||||
this.cancelScan = false
|
||||
break
|
||||
}
|
||||
}
|
||||
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
||||
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
||||
@ -161,6 +184,47 @@ class Scanner {
|
||||
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) {
|
||||
var method = req.params.method
|
||||
var query = req.query
|
||||
|
@ -42,6 +42,7 @@ class Server {
|
||||
this.clients = {}
|
||||
|
||||
this.isScanning = false
|
||||
this.isScanningCovers = false
|
||||
this.isInitialized = false
|
||||
}
|
||||
|
||||
@ -64,13 +65,28 @@ class Server {
|
||||
Logger.info('[Server] Starting Scan')
|
||||
this.isScanning = true
|
||||
this.isInitialized = true
|
||||
this.emitter('scan_start')
|
||||
this.emitter('scan_start', 'files')
|
||||
var results = await this.scanner.scan()
|
||||
this.isScanning = false
|
||||
this.emitter('scan_complete', results)
|
||||
this.emitter('scan_complete', { scanType: 'files', results })
|
||||
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() {
|
||||
Logger.info('[Server] Init')
|
||||
await this.streamManager.removeOrphanStreams()
|
||||
@ -149,6 +165,8 @@ class Server {
|
||||
|
||||
socket.on('auth', (token) => this.authenticateSocket(socket, token))
|
||||
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('close_stream', () => this.streamManager.closeStreamRequest(socket))
|
||||
socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload))
|
||||
|
67
server/utils/parseAuthors.js
Normal file
67
server/utils/parseAuthors.js
Normal 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
|
||||
}
|
||||
}
|
346
server/utils/parseFullName.js
Normal file
346
server/utils/parseFullName.js
Normal 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];
|
||||
};
|
@ -1,6 +1,7 @@
|
||||
const Path = require('path')
|
||||
const dir = require('node-dir')
|
||||
const Logger = require('../Logger')
|
||||
const parseAuthors = require('./parseAuthors')
|
||||
const { cleanString } = require('./index')
|
||||
|
||||
const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3']
|
||||
@ -74,6 +75,14 @@ async function getAllAudiobookFiles(abRootPath) {
|
||||
parts: [],
|
||||
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)
|
||||
|
Loading…
Reference in New Issue
Block a user