mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Clean and parse author name from directory, sort by author last name, scan for covers
This commit is contained in:
		
							parent
							
								
									9300a0bfb6
								
							
						
					
					
						commit
						e230cb47e8
					
				| @ -65,4 +65,8 @@ | ||||
| 
 | ||||
| .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 = {} | ||||
|       this.$store.commit('setIsScanning', false) | ||||
|       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')) | ||||
|     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() { | ||||
|       this.$store.commit('setIsScanning', true) | ||||
|     scanStart(scanType) { | ||||
|       if (scanType === 'covers') { | ||||
|         this.$store.commit('setIsScanningCovers', true) | ||||
|       } else { | ||||
|         this.$store.commit('setIsScanning', true) | ||||
|       } | ||||
|     }, | ||||
|     scanProgress(progress) { | ||||
|       this.$store.commit('setScanProgress', 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"> | ||||
|         <p class="text-2xl">Scanner</p> | ||||
|         <div class="flex-grow" /> | ||||
|         <ui-btn color="success" @click="scan">Scan</ui-btn> | ||||
|       <div class="py-4 mb-8"> | ||||
|         <div class="flex items-start py-2"> | ||||
|           <p class="text-2xl">Scanner</p> | ||||
|           <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 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": { | ||||
| @ -26,4 +26,4 @@ | ||||
|     "socket.io": "^4.1.3" | ||||
|   }, | ||||
|   "devDependencies": {} | ||||
| } | ||||
| } | ||||
| @ -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', { | ||||
|         total: audiobookDataFound.length, | ||||
|         done: i + 1, | ||||
|         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