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
					
				| @ -66,3 +66,7 @@ | |||||||
| .icon-text { | .icon-text { | ||||||
|   font-size: 1.1rem; |   font-size: 1.1rem; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | #ab-page-wrapper { | ||||||
|  |   background-image: linear-gradient(to right bottom, #2e2e2e, #303030, #313131, #333333, #353535, #343434, #323232, #313131, #2c2c2c, #282828, #232323, #1f1f1f); | ||||||
|  | } | ||||||
| @ -3,9 +3,9 @@ | |||||||
|     <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8"> |     <div id="toolbar" class="absolute top-0 left-0 w-full h-full z-20 flex items-center px-8"> | ||||||
|       <p class="font-book">{{ numShowing }} Audiobooks</p> |       <p class="font-book">{{ numShowing }} Audiobooks</p> | ||||||
|       <div class="flex-grow" /> |       <div class="flex-grow" /> | ||||||
|       <controls-filter-select v-model="settings.filterBy" class="w-40 h-7.5" @change="updateFilter" /> |       <controls-filter-select v-model="settings.filterBy" class="w-48 h-7.5" @change="updateFilter" /> | ||||||
|       <span class="px-4 text-sm">by</span> |       <span class="px-4 text-sm">by</span> | ||||||
|       <controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-40 h-7.5" @change="updateOrder" /> |       <controls-order-select v-model="settings.orderBy" :descending.sync="settings.orderDesc" class="w-48 h-7.5" @change="updateOrder" /> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|  | |||||||
| @ -2,7 +2,7 @@ | |||||||
|   <nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4"> |   <nuxt-link :to="`/audiobook/${audiobookId}`" :style="{ height: height + 32 + 'px', width: width + 32 + 'px' }" class="cursor-pointer p-4"> | ||||||
|     <div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false"> |     <div class="rounded-sm h-full overflow-hidden relative bookCard" @mouseover="isHovering = true" @mouseleave="isHovering = false"> | ||||||
|       <div class="w-full relative" :style="{ height: width * 1.6 + 'px' }"> |       <div class="w-full relative" :style="{ height: width * 1.6 + 'px' }"> | ||||||
|         <cards-book-cover :audiobook="audiobook" /> |         <cards-book-cover :audiobook="audiobook" :author-override="authorFormat" /> | ||||||
| 
 | 
 | ||||||
|         <div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> |         <div v-show="isHovering" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-40"> | ||||||
|           <div class="h-full flex items-center justify-center"> |           <div class="h-full flex items-center justify-center"> | ||||||
| @ -14,7 +14,6 @@ | |||||||
|             <span class="material-icons" style="font-size: 16px">edit</span> |             <span class="material-icons" style="font-size: 16px">edit</span> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
| 
 |  | ||||||
|         <div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> |         <div class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||||
|       </div> |       </div> | ||||||
|       <ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0"> |       <ui-tooltip v-if="showError" :text="errorText" class="absolute top-4 left-0"> | ||||||
| @ -62,6 +61,25 @@ export default { | |||||||
|     author() { |     author() { | ||||||
|       return this.book.author |       return this.book.author | ||||||
|     }, |     }, | ||||||
|  |     authorFL() { | ||||||
|  |       return this.book.authorFL || this.author | ||||||
|  |     }, | ||||||
|  |     authorLF() { | ||||||
|  |       return this.book.authorLF || this.author | ||||||
|  |     }, | ||||||
|  |     authorFormat() { | ||||||
|  |       if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null | ||||||
|  |       return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL | ||||||
|  |     }, | ||||||
|  |     volumeNumber() { | ||||||
|  |       return this.book.volumeNumber || null | ||||||
|  |     }, | ||||||
|  |     orderBy() { | ||||||
|  |       return this.$store.getters['user/getUserSetting']('orderBy') | ||||||
|  |     }, | ||||||
|  |     filterBy() { | ||||||
|  |       return this.$store.getters['user/getUserSetting']('filterBy') | ||||||
|  |     }, | ||||||
|     userProgressPercent() { |     userProgressPercent() { | ||||||
|       return this.userProgress ? this.userProgress.progress || 0 : 0 |       return this.userProgress ? this.userProgress.progress || 0 : 0 | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -8,6 +8,7 @@ | |||||||
|         <p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p> |         <p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p> | ||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|  | 
 | ||||||
|     <div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }"> |     <div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }"> | ||||||
|       <div> |       <div> | ||||||
|         <p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p> |         <p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p> | ||||||
| @ -26,6 +27,7 @@ export default { | |||||||
|       type: Object, |       type: Object, | ||||||
|       default: () => {} |       default: () => {} | ||||||
|     }, |     }, | ||||||
|  |     authorOverride: String, | ||||||
|     width: { |     width: { | ||||||
|       type: Number, |       type: Number, | ||||||
|       default: 120 |       default: 120 | ||||||
| @ -36,6 +38,11 @@ export default { | |||||||
|       imageFailed: false |       imageFailed: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   watch: { | ||||||
|  |     cover() { | ||||||
|  |       this.imageFailed = false | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     book() { |     book() { | ||||||
|       return this.audiobook.book || {} |       return this.audiobook.book || {} | ||||||
| @ -50,6 +57,7 @@ export default { | |||||||
|       return this.title |       return this.title | ||||||
|     }, |     }, | ||||||
|     author() { |     author() { | ||||||
|  |       if (this.authorOverride) return this.authorOverride | ||||||
|       return this.book.author || 'Unknown' |       return this.book.author || 'Unknown' | ||||||
|     }, |     }, | ||||||
|     authorCleaned() { |     authorCleaned() { | ||||||
|  | |||||||
| @ -115,6 +115,9 @@ export default { | |||||||
|       if (!_sel) return '' |       if (!_sel) return '' | ||||||
|       return _sel.text |       return _sel.text | ||||||
|     }, |     }, | ||||||
|  |     authors() { | ||||||
|  |       return this.$store.getters['audiobooks/getUniqueAuthors'] | ||||||
|  |     }, | ||||||
|     genres() { |     genres() { | ||||||
|       return this.$store.state.audiobooks.genres |       return this.$store.state.audiobooks.genres | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -11,7 +11,7 @@ | |||||||
|       <template v-for="item in items"> |       <template v-for="item in items"> | ||||||
|         <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> |         <li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)"> | ||||||
|           <div class="flex items-center"> |           <div class="flex items-center"> | ||||||
|             <span class="font-normal ml-3 block truncate">{{ item.text }}</span> |             <span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span> | ||||||
|           </div> |           </div> | ||||||
|           <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> |           <span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4"> | ||||||
|             <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> |             <span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span> | ||||||
| @ -37,8 +37,12 @@ export default { | |||||||
|           value: 'book.title' |           value: 'book.title' | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           text: 'Author', |           text: 'Author (First Last)', | ||||||
|           value: 'book.author' |           value: 'book.authorFL' | ||||||
|  |         }, | ||||||
|  |         { | ||||||
|  |           text: 'Author (Last, First)', | ||||||
|  |           value: 'book.authorLF' | ||||||
|         }, |         }, | ||||||
|         { |         { | ||||||
|           text: 'Added At', |           text: 'Added At', | ||||||
| @ -73,7 +77,8 @@ export default { | |||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|     selectedText() { |     selectedText() { | ||||||
|       var _sel = this.items.find((i) => i.value === this.selected) |       var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected | ||||||
|  |       var _sel = this.items.find((i) => i.value === _selected) | ||||||
|       if (!_sel) return '' |       if (!_sel) return '' | ||||||
|       return _sel.text |       return _sel.text | ||||||
|     } |     } | ||||||
|  | |||||||
| @ -21,9 +21,14 @@ | |||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <!-- <ui-text-input-with-label v-model="details.series" label="Series" class="mt-2" /> --> |       <div class="flex mt-2 -mx-1"> | ||||||
| 
 |         <div class="w-3/4 px-1"> | ||||||
|       <ui-input-dropdown v-model="details.series" label="Series" class="mt-2" :items="series" /> |           <ui-input-dropdown v-model="details.series" label="Series" :items="series" /> | ||||||
|  |         </div> | ||||||
|  |         <div class="flex-grow px-1"> | ||||||
|  |           <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
| 
 | 
 | ||||||
|       <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> |       <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> | ||||||
| 
 | 
 | ||||||
| @ -61,6 +66,7 @@ export default { | |||||||
|         description: null, |         description: null, | ||||||
|         author: null, |         author: null, | ||||||
|         series: null, |         series: null, | ||||||
|  |         volumeNumber: null, | ||||||
|         publishYear: null, |         publishYear: null, | ||||||
|         genres: [] |         genres: [] | ||||||
|       }, |       }, | ||||||
| @ -132,6 +138,7 @@ export default { | |||||||
|       this.details.author = this.book.author |       this.details.author = this.book.author | ||||||
|       this.details.genres = this.book.genres || [] |       this.details.genres = this.book.genres || [] | ||||||
|       this.details.series = this.book.series |       this.details.series = this.book.series | ||||||
|  |       this.details.volumeNumber = this.book.volumeNumber | ||||||
|       this.details.publishYear = this.book.publishYear |       this.details.publishYear = this.book.publishYear | ||||||
| 
 | 
 | ||||||
|       this.newTags = this.audiobook.tags || [] |       this.newTags = this.audiobook.tags || [] | ||||||
|  | |||||||
| @ -3,12 +3,12 @@ | |||||||
|     <p class="px-1 text-sm font-semibold">{{ label }}</p> |     <p class="px-1 text-sm font-semibold">{{ label }}</p> | ||||||
|     <div ref="wrapper" class="relative"> |     <div ref="wrapper" class="relative"> | ||||||
|       <form @submit.prevent="submitForm"> |       <form @submit.prevent="submitForm"> | ||||||
|         <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text"> |         <div ref="inputWrapper" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded px-2 py-2"> | ||||||
|           <input ref="input" v-model="textInput" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> |           <input ref="input" v-model="textInput" class="h-full w-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> | ||||||
|         </div> |         </div> | ||||||
|       </form> |       </form> | ||||||
| 
 | 
 | ||||||
|       <ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> |       <ul ref="menu" v-show="isFocused && items.length && (itemsToShow.length || currentSearch)" class="absolute z-50 mt-0 w-full bg-bg border border-black-200 shadow-lg max-h-56 rounded py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label"> | ||||||
|         <template v-for="item in itemsToShow"> |         <template v-for="item in itemsToShow"> | ||||||
|           <li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> |           <li :key="item" class="text-gray-50 select-none relative py-2 pr-3 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|             <div class="flex items-center"> |             <div class="flex items-center"> | ||||||
|  | |||||||
| @ -4,7 +4,12 @@ | |||||||
|     <div ref="wrapper" class="relative"> |     <div ref="wrapper" class="relative"> | ||||||
|       <form @submit.prevent="submitForm"> |       <form @submit.prevent="submitForm"> | ||||||
|         <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> |         <div ref="inputWrapper" style="min-height: 40px" class="flex-wrap relative w-full shadow-sm flex items-center bg-primary border border-gray-600 rounded-md px-2 py-1 cursor-text" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent> | ||||||
|           <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center">{{ $snakeToNormal(item) }}</div> |           <div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 ma-0.5 text-xs bg-bg flex flex-nowrap whitespace-nowrap items-center relative"> | ||||||
|  |             <div class="w-full h-full rounded-full absolute top-0 left-0 opacity-0 hover:opacity-100 px-1 bg-bg bg-opacity-75 flex items-center justify-end cursor-pointer"> | ||||||
|  |               <span class="material-icons text-white hover:text-error" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span> | ||||||
|  |             </div> | ||||||
|  |             {{ $snakeToNormal(item) }} | ||||||
|  |           </div> | ||||||
|           <input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> |           <input ref="input" v-model="textInput" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" /> | ||||||
|         </div> |         </div> | ||||||
|       </form> |       </form> | ||||||
| @ -156,6 +161,13 @@ export default { | |||||||
|       } |       } | ||||||
|       this.focus() |       this.focus() | ||||||
|     }, |     }, | ||||||
|  |     removeItem(item) { | ||||||
|  |       var remaining = this.selected.filter((i) => i !== item) | ||||||
|  |       this.$emit('input', remaining) | ||||||
|  |       this.$nextTick(() => { | ||||||
|  |         this.recalcMenuPos() | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|     insertNewItem(item) { |     insertNewItem(item) { | ||||||
|       var kebabItem = this.$normalToSnake(item) |       var kebabItem = this.$normalToSnake(item) | ||||||
|       this.selected.push(kebabItem) |       this.selected.push(kebabItem) | ||||||
|  | |||||||
| @ -1,25 +1,47 @@ | |||||||
| <template> | <template> | ||||||
|   <div v-show="isScanning" class="fixed bottom-0 left-0 right-0 mx-auto z-20 max-w-lg"> |   <div v-show="isScanning" class="fixed bottom-4 left-0 right-0 mx-auto z-20 max-w-lg"> | ||||||
|     <div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning"> |     <div class="w-full my-1 rounded-lg drop-shadow-lg px-4 py-2 flex items-center justify-center text-center transition-all border border-white border-opacity-40 shadow-md bg-warning"> | ||||||
|       <p class="text-lg font-sans" v-html="text" /> |       <p class="text-lg font-sans" v-html="text" /> | ||||||
|     </div> |     </div> | ||||||
|  |     <div v-show="!hasCanceled" class="absolute right-0 top-3 bottom-0 px-2"> | ||||||
|  |       <ui-btn color="red-600" small :padding-x="1" @click="cancelScan">Cancel</ui-btn> | ||||||
|  |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
| export default { | export default { | ||||||
|   data() { |   data() { | ||||||
|     return {} |     return { | ||||||
|  |       hasCanceled: false | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     isScanning(newVal) { | ||||||
|  |       if (newVal) { | ||||||
|  |         this.hasCanceled = false | ||||||
|  |       } | ||||||
|  |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|     text() { |     text() { | ||||||
|       return `Scanning... <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>` |       var scanText = this.isScanningFiles ? 'Scanning...' : 'Scanning Covers...' | ||||||
|  |       return `${scanText} <span class="font-mono">${this.scanNum}</span> of <span class="font-mono">${this.scanTotal}</span> <strong class='font-mono px-2'>${this.scanPercent}</strong>` | ||||||
|     }, |     }, | ||||||
|     isScanning() { |     isScanning() { | ||||||
|  |       return this.isScanningFiles || this.isScanningCovers | ||||||
|  |     }, | ||||||
|  |     isScanningFiles() { | ||||||
|       return this.$store.state.isScanning |       return this.$store.state.isScanning | ||||||
|     }, |     }, | ||||||
|  |     isScanningCovers() { | ||||||
|  |       return this.$store.state.isScanningCovers | ||||||
|  |     }, | ||||||
|  |     scanProgressKey() { | ||||||
|  |       return this.isScanningFiles ? 'scanProgress' : 'coverScanProgress' | ||||||
|  |     }, | ||||||
|     scanProgress() { |     scanProgress() { | ||||||
|       return this.$store.state.scanProgress |       return this.$store.state[this.scanProgressKey] | ||||||
|     }, |     }, | ||||||
|     scanPercent() { |     scanPercent() { | ||||||
|       return this.scanProgress ? this.scanProgress.progress + '%' : '0%' |       return this.scanProgress ? this.scanProgress.progress + '%' : '0%' | ||||||
| @ -31,7 +53,12 @@ export default { | |||||||
|       return this.scanProgress ? this.scanProgress.total : 0 |       return this.scanProgress ? this.scanProgress.total : 0 | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: {}, |   methods: { | ||||||
|  |     cancelScan() { | ||||||
|  |       this.hasCanceled = true | ||||||
|  |       this.$root.socket.emit('cancel_scan') | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   mounted() {} |   mounted() {} | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| @ -83,21 +83,37 @@ export default { | |||||||
|       } |       } | ||||||
|       this.$store.commit('audiobooks/remove', audiobook) |       this.$store.commit('audiobooks/remove', audiobook) | ||||||
|     }, |     }, | ||||||
|     scanComplete(results) { |     scanComplete({ scanType, results }) { | ||||||
|       if (!results) results = {} |       if (scanType === 'covers') { | ||||||
|       this.$store.commit('setIsScanning', false) |         this.$store.commit('setIsScanningCovers', false) | ||||||
|       var scanResultMsgs = [] |         if (results) { | ||||||
|       if (results.added) scanResultMsgs.push(`${results.added} added`) |           this.$toast.success(`Scan Finished\nUpdated ${results.found} covers`) | ||||||
|       if (results.updated) scanResultMsgs.push(`${results.updated} updated`) |         } | ||||||
|       if (results.removed) scanResultMsgs.push(`${results.removed} removed`) |       } else { | ||||||
|       if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date') |         this.$store.commit('setIsScanning', false) | ||||||
|       else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n')) |         if (results) { | ||||||
|  |           var scanResultMsgs = [] | ||||||
|  |           if (results.added) scanResultMsgs.push(`${results.added} added`) | ||||||
|  |           if (results.updated) scanResultMsgs.push(`${results.updated} updated`) | ||||||
|  |           if (results.removed) scanResultMsgs.push(`${results.removed} removed`) | ||||||
|  |           if (!scanResultMsgs.length) this.$toast.success('Scan Finished\nEverything was up to date') | ||||||
|  |           else this.$toast.success('Scan Finished\n' + scanResultMsgs.join('\n')) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     scanStart() { |     scanStart(scanType) { | ||||||
|       this.$store.commit('setIsScanning', true) |       if (scanType === 'covers') { | ||||||
|  |         this.$store.commit('setIsScanningCovers', true) | ||||||
|  |       } else { | ||||||
|  |         this.$store.commit('setIsScanning', true) | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     scanProgress(progress) { |     scanProgress({ scanType, progress }) { | ||||||
|       this.$store.commit('setScanProgress', progress) |       if (scanType === 'covers') { | ||||||
|  |         this.$store.commit('setCoverScanProgress', progress) | ||||||
|  |       } else { | ||||||
|  |         this.$store.commit('setScanProgress', progress) | ||||||
|  |       } | ||||||
|     }, |     }, | ||||||
|     userUpdated(user) { |     userUpdated(user) { | ||||||
|       if (this.$store.state.user.user.id === user.id) { |       if (this.$store.state.user.user.id === user.id) { | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "0.9.72-beta", |   "version": "0.9.73-beta", | ||||||
|   "description": "Audiobook manager and player", |   "description": "Audiobook manager and player", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''"> |   <div id="ab-page-wrapper" class="bg-bg page overflow-hidden relative" :class="streamAudiobook ? 'streaming' : ''"> | ||||||
|     <div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center"> |     <div v-show="saving" class="absolute z-20 w-full h-full flex items-center justify-center"> | ||||||
|       <ui-loading-indicator /> |       <ui-loading-indicator /> | ||||||
|     </div> |     </div> | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''"> |   <div id="ab-page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''"> | ||||||
|     <div class="w-full h-full overflow-y-auto p-8"> |     <div class="w-full h-full overflow-y-auto p-8"> | ||||||
|       <div class="flex max-w-6xl mx-auto"> |       <div class="flex max-w-6xl mx-auto"> | ||||||
|         <div class="w-52" style="min-width: 208px"> |         <div class="w-52" style="min-width: 208px"> | ||||||
| @ -10,7 +10,11 @@ | |||||||
|         </div> |         </div> | ||||||
|         <div class="flex-grow px-10"> |         <div class="flex-grow px-10"> | ||||||
|           <div class="flex"> |           <div class="flex"> | ||||||
|             <h1 class="text-2xl">{{ title }}</h1> |             <div class="mb-2"> | ||||||
|  |               <h1 class="text-2xl font-book leading-7">{{ title }}</h1> | ||||||
|  |               <h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3> | ||||||
|  |               <p class="text-sm text-gray-100 leading-7">by {{ author }}</p> | ||||||
|  |             </div> | ||||||
|             <div class="flex-grow" /> |             <div class="flex-grow" /> | ||||||
|           </div> |           </div> | ||||||
|           <p class="text-gray-300 text-sm my-1"> |           <p class="text-gray-300 text-sm my-1"> | ||||||
| @ -133,6 +137,17 @@ export default { | |||||||
|     author() { |     author() { | ||||||
|       return this.book.author || 'Unknown' |       return this.book.author || 'Unknown' | ||||||
|     }, |     }, | ||||||
|  |     series() { | ||||||
|  |       return this.book.series || null | ||||||
|  |     }, | ||||||
|  |     volumeNumber() { | ||||||
|  |       return this.book.volumeNumber || null | ||||||
|  |     }, | ||||||
|  |     seriesText() { | ||||||
|  |       if (!this.series) return '' | ||||||
|  |       if (!this.volumeNumber) return this.series | ||||||
|  |       return `${this.series} #${this.volumeNumber}` | ||||||
|  |     }, | ||||||
|     durationPretty() { |     durationPretty() { | ||||||
|       return this.audiobook.durationPretty |       return this.audiobook.durationPretty | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -26,10 +26,15 @@ | |||||||
|         </table> |         </table> | ||||||
|       </div> |       </div> | ||||||
|       <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> |       <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> | ||||||
|       <div class="flex items-center py-4 mb-8"> |       <div class="py-4 mb-8"> | ||||||
|         <p class="text-2xl">Scanner</p> |         <div class="flex items-start py-2"> | ||||||
|         <div class="flex-grow" /> |           <p class="text-2xl">Scanner</p> | ||||||
|         <ui-btn color="success" @click="scan">Scan</ui-btn> |           <div class="flex-grow" /> | ||||||
|  |           <div class="w-40 flex flex-col"> | ||||||
|  |             <ui-btn color="success" class="mb-4" :loading="isScanning" :disabled="isScanningCovers" @click="scan">Scan</ui-btn> | ||||||
|  |             <ui-btn color="primary" small :padding-x="2" :loading="isScanningCovers" :disabled="isScanning" @click="scanCovers">Scan for Covers</ui-btn> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|       </div> |       </div> | ||||||
| 
 | 
 | ||||||
|       <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> |       <div class="h-0.5 bg-primary bg-opacity-50 w-full" /> | ||||||
| @ -68,6 +73,12 @@ export default { | |||||||
|   computed: { |   computed: { | ||||||
|     streamAudiobook() { |     streamAudiobook() { | ||||||
|       return this.$store.state.streamAudiobook |       return this.$store.state.streamAudiobook | ||||||
|  |     }, | ||||||
|  |     isScanning() { | ||||||
|  |       return this.$store.state.isScanning | ||||||
|  |     }, | ||||||
|  |     isScanningCovers() { | ||||||
|  |       return this.$store.state.isScanningCovers | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
| @ -79,6 +90,9 @@ export default { | |||||||
|     scan() { |     scan() { | ||||||
|       this.$root.socket.emit('scan') |       this.$root.socket.emit('scan') | ||||||
|     }, |     }, | ||||||
|  |     scanCovers() { | ||||||
|  |       this.$root.socket.emit('scan_covers') | ||||||
|  |     }, | ||||||
|     clickAddUser() { |     clickAddUser() { | ||||||
|       this.$toast.info('Under Construction: User management coming soon.') |       this.$toast.info('Under Construction: User management coming soon.') | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -109,6 +109,21 @@ Vue.prototype.$codeToString = (code) => { | |||||||
|   return finalform |   return finalform | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | function cleanString(str, availableChars) { | ||||||
|  |   var _str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") | ||||||
|  |   var cleaned = '' | ||||||
|  |   for (let i = 0; i < _str.length; i++) { | ||||||
|  |     cleaned += availableChars.indexOf(str[i]) < 0 ? '' : str[i] | ||||||
|  |   } | ||||||
|  |   return cleaned | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export const cleanFilterString = (str) => { | ||||||
|  |   var _str = str.toLowerCase().replace(/ /g, '_') | ||||||
|  |   _str = cleanString(_str, "0123456789abcdefghijklmnopqrstuvwxyz") | ||||||
|  |   return _str | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function loadImageBlob(uri) { | function loadImageBlob(uri) { | ||||||
|   return new Promise((resolve) => { |   return new Promise((resolve) => { | ||||||
|     const img = document.createElement('img') |     const img = document.createElement('img') | ||||||
|  | |||||||
| @ -1,4 +1,5 @@ | |||||||
| import { sort } from '@/assets/fastSort' | import { sort } from '@/assets/fastSort' | ||||||
|  | import { cleanFilterString } from '@/plugins/init.client' | ||||||
| 
 | 
 | ||||||
| const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] | const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] | ||||||
| 
 | 
 | ||||||
| @ -16,13 +17,14 @@ export const getters = { | |||||||
|     var settings = rootState.user.settings || {} |     var settings = rootState.user.settings || {} | ||||||
|     var filterBy = settings.filterBy || '' |     var filterBy = settings.filterBy || '' | ||||||
| 
 | 
 | ||||||
|     var searchGroups = ['genres', 'tags', 'series'] |     var searchGroups = ['genres', 'tags', 'series', 'authors'] | ||||||
|     var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) |     var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) | ||||||
|     if (group) { |     if (group) { | ||||||
|       var filter = filterBy.replace(`${group}.`, '') |       var filter = filterBy.replace(`${group}.`, '') | ||||||
|       if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) |       if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter)) | ||||||
|       else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) |       else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter)) | ||||||
|       else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) |       else if (group === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter) | ||||||
|  |       else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter) | ||||||
|     } |     } | ||||||
|     return filtered |     return filtered | ||||||
|   }, |   }, | ||||||
| @ -35,6 +37,10 @@ export const getters = { | |||||||
|       // Supports dot notation strings i.e. "book.title"
 |       // Supports dot notation strings i.e. "book.title"
 | ||||||
|       return settings.orderBy.split('.').reduce((a, b) => a[b], ab) |       return settings.orderBy.split('.').reduce((a, b) => a[b], ab) | ||||||
|     }) |     }) | ||||||
|  |   }, | ||||||
|  |   getUniqueAuthors: (state) => { | ||||||
|  |     var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author) | ||||||
|  |     return [...new Set(_authors)] | ||||||
|   } |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -5,7 +5,9 @@ export const state = () => ({ | |||||||
|   selectedAudiobook: null, |   selectedAudiobook: null, | ||||||
|   playOnLoad: false, |   playOnLoad: false, | ||||||
|   isScanning: false, |   isScanning: false, | ||||||
|  |   isScanningCovers: false, | ||||||
|   scanProgress: null, |   scanProgress: null, | ||||||
|  |   coverScanProgress: null, | ||||||
|   developerMode: false |   developerMode: false | ||||||
| }) | }) | ||||||
| 
 | 
 | ||||||
| @ -41,9 +43,16 @@ export const mutations = { | |||||||
|   setIsScanning(state, isScanning) { |   setIsScanning(state, isScanning) { | ||||||
|     state.isScanning = isScanning |     state.isScanning = isScanning | ||||||
|   }, |   }, | ||||||
|   setScanProgress(state, progress) { |   setScanProgress(state, scanProgress) { | ||||||
|     if (progress > 0) state.isScanning = true |     if (scanProgress && scanProgress.progress > 0) state.isScanning = true | ||||||
|     state.scanProgress = progress |     state.scanProgress = scanProgress | ||||||
|  |   }, | ||||||
|  |   setIsScanningCovers(state, isScanningCovers) { | ||||||
|  |     state.isScanningCovers = isScanningCovers | ||||||
|  |   }, | ||||||
|  |   setCoverScanProgress(state, coverScanProgress) { | ||||||
|  |     if (coverScanProgress && coverScanProgress.progress > 0) state.isScanningCovers = true | ||||||
|  |     state.coverScanProgress = coverScanProgress | ||||||
|   }, |   }, | ||||||
|   setDeveloperMode(state, val) { |   setDeveloperMode(state, val) { | ||||||
|     state.developerMode = val |     state.developerMode = val | ||||||
|  | |||||||
| @ -16,12 +16,11 @@ module.exports = { | |||||||
|       }, |       }, | ||||||
|       colors: { |       colors: { | ||||||
|         bg: '#373838', |         bg: '#373838', | ||||||
|         primary: '#262626', |         primary: '#232323', | ||||||
|         accent: '#1ad691', |         accent: '#1ad691', | ||||||
|         error: '#FF5252', |         error: '#FF5252', | ||||||
|         info: '#2196F3', |         info: '#2196F3', | ||||||
|         success: '#4CAF50', |         success: '#4CAF50', | ||||||
|         successDark: '#3b8a3e', |  | ||||||
|         warning: '#FB8C00', |         warning: '#FB8C00', | ||||||
|         'black-50': '#bbbbbb', |         'black-50': '#bbbbbb', | ||||||
|         'black-100': '#666666', |         'black-100': '#666666', | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf", |   "name": "audiobookshelf", | ||||||
|   "version": "0.9.72-beta", |   "version": "0.9.73-beta", | ||||||
|   "description": "Self-hosted audiobook server for managing and playing audiobooks.", |   "description": "Self-hosted audiobook server for managing and playing audiobooks.", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -62,6 +62,10 @@ class Audiobook { | |||||||
|     return this.book ? this.book.author : 'Unknown' |     return this.book ? this.book.author : 'Unknown' | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   get authorLF() { | ||||||
|  |     return this.book ? this.book.authorLF : null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   get genres() { |   get genres() { | ||||||
|     return this.book ? this.book.genres || [] : [] |     return this.book ? this.book.genres || [] : [] | ||||||
|   } |   } | ||||||
| @ -136,9 +140,9 @@ class Audiobook { | |||||||
|   toJSONExpanded() { |   toJSONExpanded() { | ||||||
|     return { |     return { | ||||||
|       id: this.id, |       id: this.id, | ||||||
|       title: this.title, |       // title: this.title,
 | ||||||
|       author: this.author, |       // author: this.author,
 | ||||||
|       cover: this.cover, |       // cover: this.cover,
 | ||||||
|       path: this.path, |       path: this.path, | ||||||
|       fullPath: this.fullPath, |       fullPath: this.fullPath, | ||||||
|       addedAt: this.addedAt, |       addedAt: this.addedAt, | ||||||
| @ -306,6 +310,10 @@ class Audiobook { | |||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   syncAuthorNames(audiobookData) { | ||||||
|  |     return this.book.syncAuthorNames(audiobookData.authorFL, audiobookData.authorLF) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   isSearchMatch(search) { |   isSearchMatch(search) { | ||||||
|     return this.book.isSearchMatch(search.toLowerCase().trim()) |     return this.book.isSearchMatch(search.toLowerCase().trim()) | ||||||
|   } |   } | ||||||
|  | |||||||
| @ -4,7 +4,10 @@ class Book { | |||||||
|     this.olid = null |     this.olid = null | ||||||
|     this.title = null |     this.title = null | ||||||
|     this.author = null |     this.author = null | ||||||
|  |     this.authorFL = null | ||||||
|  |     this.authorLF = null | ||||||
|     this.series = null |     this.series = null | ||||||
|  |     this.volumeNumber = null | ||||||
|     this.publishYear = null |     this.publishYear = null | ||||||
|     this.publisher = null |     this.publisher = null | ||||||
|     this.description = null |     this.description = null | ||||||
| @ -24,7 +27,10 @@ class Book { | |||||||
|     this.olid = book.olid |     this.olid = book.olid | ||||||
|     this.title = book.title |     this.title = book.title | ||||||
|     this.author = book.author |     this.author = book.author | ||||||
|  |     this.authorFL = book.authorFL || null | ||||||
|  |     this.authorLF = book.authorLF || null | ||||||
|     this.series = book.series |     this.series = book.series | ||||||
|  |     this.volumeNumber = book.volumeNumber || null | ||||||
|     this.publishYear = book.publishYear |     this.publishYear = book.publishYear | ||||||
|     this.publisher = book.publisher |     this.publisher = book.publisher | ||||||
|     this.description = book.description |     this.description = book.description | ||||||
| @ -37,7 +43,10 @@ class Book { | |||||||
|       olid: this.olid, |       olid: this.olid, | ||||||
|       title: this.title, |       title: this.title, | ||||||
|       author: this.author, |       author: this.author, | ||||||
|  |       authorFL: this.authorFL, | ||||||
|  |       authorLF: this.authorLF, | ||||||
|       series: this.series, |       series: this.series, | ||||||
|  |       volumeNumber: this.volumeNumber, | ||||||
|       publishYear: this.publishYear, |       publishYear: this.publishYear, | ||||||
|       publisher: this.publisher, |       publisher: this.publisher, | ||||||
|       description: this.description, |       description: this.description, | ||||||
| @ -50,7 +59,10 @@ class Book { | |||||||
|     this.olid = data.olid || null |     this.olid = data.olid || null | ||||||
|     this.title = data.title || null |     this.title = data.title || null | ||||||
|     this.author = data.author || null |     this.author = data.author || null | ||||||
|  |     this.authorLF = data.authorLF || null | ||||||
|  |     this.authorFL = data.authorFL || null | ||||||
|     this.series = data.series || null |     this.series = data.series || null | ||||||
|  |     this.volumeNumber = data.volumeNumber || null | ||||||
|     this.publishYear = data.publishYear || null |     this.publishYear = data.publishYear || null | ||||||
|     this.description = data.description || null |     this.description = data.description || null | ||||||
|     this.cover = data.cover || null |     this.cover = data.cover || null | ||||||
| @ -83,7 +95,20 @@ class Book { | |||||||
|         hasUpdates = true |         hasUpdates = true | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|     return true |     return hasUpdates | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   syncAuthorNames(authorFL, authorLF) { | ||||||
|  |     var hasUpdates = false | ||||||
|  |     if (authorFL !== this.authorFL) { | ||||||
|  |       this.authorFL = authorFL | ||||||
|  |       hasUpdates = true | ||||||
|  |     } | ||||||
|  |     if (authorLF !== this.authorLF) { | ||||||
|  |       this.authorLF = authorLF | ||||||
|  |       hasUpdates = true | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   isSearchMatch(search) { |   isSearchMatch(search) { | ||||||
|  | |||||||
| @ -26,7 +26,17 @@ class BookFinder { | |||||||
|     return title |     return title | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   replaceAccentedChars(str) { | ||||||
|  |     try { | ||||||
|  |       return str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") | ||||||
|  |     } catch (error) { | ||||||
|  |       Logger.error('[BookFinder] str normalize error', error) | ||||||
|  |       return str | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   cleanTitleForCompares(title) { |   cleanTitleForCompares(title) { | ||||||
|  |     if (!title) return '' | ||||||
|     // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
 |     // Remove subtitle if there (i.e. "Cool Book: Coolest Ever" becomes "Cool Book")
 | ||||||
|     var stripped = this.stripSubtitle(title) |     var stripped = this.stripSubtitle(title) | ||||||
| 
 | 
 | ||||||
| @ -35,16 +45,34 @@ class BookFinder { | |||||||
| 
 | 
 | ||||||
|     // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
 |     // Remove single quotes (i.e. "Ender's Game" becomes "Enders Game")
 | ||||||
|     cleaned = cleaned.replace(/'/g, '') |     cleaned = cleaned.replace(/'/g, '') | ||||||
|  |     cleaned = this.replaceAccentedChars(cleaned) | ||||||
|  |     return cleaned.toLowerCase() | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   cleanAuthorForCompares(author) { | ||||||
|  |     if (!author) return '' | ||||||
|  |     var cleaned = this.replaceAccentedChars(author) | ||||||
|     return cleaned.toLowerCase() |     return cleaned.toLowerCase() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { |   filterSearchResults(books, title, author, maxTitleDistance, maxAuthorDistance) { | ||||||
|     var searchTitle = this.cleanTitleForCompares(title) |     var searchTitle = this.cleanTitleForCompares(title) | ||||||
|  |     var searchAuthor = this.cleanAuthorForCompares(author) | ||||||
|     return books.map(b => { |     return books.map(b => { | ||||||
|       b.cleanedTitle = this.cleanTitleForCompares(b.title) |       b.cleanedTitle = this.cleanTitleForCompares(b.title) | ||||||
|       b.titleDistance = levenshteinDistance(b.cleanedTitle, title) |       b.titleDistance = levenshteinDistance(b.cleanedTitle, title) | ||||||
|       if (author) { |       if (author) { | ||||||
|         b.authorDistance = levenshteinDistance(b.author || '', author) |         if (!b.author) { | ||||||
|  |           b.authorDistance = author.length | ||||||
|  |         } else { | ||||||
|  |           b.cleanedAuthor = this.cleanAuthorForCompares(b.author) | ||||||
|  | 
 | ||||||
|  |           var cleanedAuthorDistance = levenshteinDistance(b.cleanedAuthor, searchAuthor) | ||||||
|  |           var authorDistance = levenshteinDistance(b.author || '', author) | ||||||
|  |           // Use best distance
 | ||||||
|  |           if (cleanedAuthorDistance > authorDistance) b.authorDistance = authorDistance | ||||||
|  |           else b.authorDistance = cleanedAuthorDistance | ||||||
|  |         } | ||||||
|       } |       } | ||||||
|       b.totalDistance = b.titleDistance + (b.authorDistance || 0) |       b.totalDistance = b.titleDistance + (b.authorDistance || 0) | ||||||
|       b.totalPossibleDistance = b.title.length |       b.totalPossibleDistance = b.title.length | ||||||
| @ -142,7 +170,8 @@ class BookFinder { | |||||||
| 
 | 
 | ||||||
|   async findCovers(provider, title, author, options = {}) { |   async findCovers(provider, title, author, options = {}) { | ||||||
|     var searchResults = await this.search(provider, title, author, options) |     var searchResults = await this.search(provider, title, author, options) | ||||||
|     console.log('Find Covers search results', searchResults) |     Logger.info(`[BookFinder] FindCovers search results: ${searchResults.length}`) | ||||||
|  | 
 | ||||||
|     var covers = [] |     var covers = [] | ||||||
|     searchResults.forEach((result) => { |     searchResults.forEach((result) => { | ||||||
|       if (result.covers && result.covers.length) { |       if (result.covers && result.covers.length) { | ||||||
|  | |||||||
| @ -13,6 +13,8 @@ class Scanner { | |||||||
|     this.db = db |     this.db = db | ||||||
|     this.emitter = emitter |     this.emitter = emitter | ||||||
| 
 | 
 | ||||||
|  |     this.cancelScan = false | ||||||
|  | 
 | ||||||
|     this.bookFinder = new BookFinder() |     this.bookFinder = new BookFinder() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -34,6 +36,11 @@ class Scanner { | |||||||
|     const scanStart = Date.now() |     const scanStart = Date.now() | ||||||
|     var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath) |     var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath) | ||||||
| 
 | 
 | ||||||
|  |     if (this.cancelScan) { | ||||||
|  |       this.cancelScan = false | ||||||
|  |       return null | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     var scanResults = { |     var scanResults = { | ||||||
|       removed: 0, |       removed: 0, | ||||||
|       updated: 0, |       updated: 0, | ||||||
| @ -54,6 +61,10 @@ class Scanner { | |||||||
|         scanResults.removed++ |         scanResults.removed++ | ||||||
|         this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified()) |         this.emitter('audiobook_removed', this.audiobooks[i].toJSONMinified()) | ||||||
|       } |       } | ||||||
|  |       if (this.cancelScan) { | ||||||
|  |         this.cancelScan = false | ||||||
|  |         return null | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     for (let i = 0; i < audiobookDataFound.length; i++) { |     for (let i = 0; i < audiobookDataFound.length; i++) { | ||||||
| @ -109,6 +120,11 @@ class Scanner { | |||||||
|               hasUpdates = true |               hasUpdates = true | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|  |             if (audiobookData.author && existingAudiobook.syncAuthorNames(audiobookData)) { | ||||||
|  |               Logger.info(`[Scanner] "${existingAudiobook.title}" author names updated, "${existingAudiobook.authorLF}"`) | ||||||
|  |               hasUpdates = true | ||||||
|  |             } | ||||||
|  | 
 | ||||||
|             if (hasUpdates) { |             if (hasUpdates) { | ||||||
|               Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`) |               Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`) | ||||||
|               existingAudiobook.lastUpdate = Date.now() |               existingAudiobook.lastUpdate = Date.now() | ||||||
| @ -138,10 +154,17 @@ class Scanner { | |||||||
|       } |       } | ||||||
|       var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) |       var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) | ||||||
|       this.emitter('scan_progress', { |       this.emitter('scan_progress', { | ||||||
|         total: audiobookDataFound.length, |         scanType: 'files', | ||||||
|         done: i + 1, |         progress: { | ||||||
|         progress |           total: audiobookDataFound.length, | ||||||
|  |           done: i + 1, | ||||||
|  |           progress | ||||||
|  |         } | ||||||
|       }) |       }) | ||||||
|  |       if (this.cancelScan) { | ||||||
|  |         this.cancelScan = false | ||||||
|  |         break | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|     const scanElapsed = Math.floor((Date.now() - scanStart) / 1000) |     const scanElapsed = Math.floor((Date.now() - scanStart) / 1000) | ||||||
|     Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`) |     Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | elapsed: ${secondsToTimestamp(scanElapsed)}`) | ||||||
| @ -161,6 +184,47 @@ class Scanner { | |||||||
|     return scanResult |     return scanResult | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async scanCovers() { | ||||||
|  |     var audiobooksNeedingCover = this.audiobooks.filter(ab => !ab.cover && ab.author) | ||||||
|  |     var found = 0 | ||||||
|  |     var notFound = 0 | ||||||
|  |     for (let i = 0; i < audiobooksNeedingCover.length; i++) { | ||||||
|  |       var audiobook = audiobooksNeedingCover[i] | ||||||
|  |       var options = { | ||||||
|  |         titleDistance: 2, | ||||||
|  |         authorDistance: 2 | ||||||
|  |       } | ||||||
|  |       var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.author, options) | ||||||
|  |       if (results.length) { | ||||||
|  |         Logger.info(`[Scanner] Found best cover for "${audiobook.title}"`) | ||||||
|  |         audiobook.book.cover = results[0] | ||||||
|  |         await this.db.updateAudiobook(audiobook) | ||||||
|  |         found++ | ||||||
|  |       } else { | ||||||
|  |         notFound++ | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var progress = Math.round(100 * (i + 1) / audiobooksNeedingCover.length) | ||||||
|  |       this.emitter('scan_progress', { | ||||||
|  |         scanType: 'covers', | ||||||
|  |         progress: { | ||||||
|  |           total: audiobooksNeedingCover.length, | ||||||
|  |           done: i + 1, | ||||||
|  |           progress | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  | 
 | ||||||
|  |       if (this.cancelScan) { | ||||||
|  |         this.cancelScan = false | ||||||
|  |         break | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return { | ||||||
|  |       found, | ||||||
|  |       notFound | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async find(req, res) { |   async find(req, res) { | ||||||
|     var method = req.params.method |     var method = req.params.method | ||||||
|     var query = req.query |     var query = req.query | ||||||
|  | |||||||
| @ -42,6 +42,7 @@ class Server { | |||||||
|     this.clients = {} |     this.clients = {} | ||||||
| 
 | 
 | ||||||
|     this.isScanning = false |     this.isScanning = false | ||||||
|  |     this.isScanningCovers = false | ||||||
|     this.isInitialized = false |     this.isInitialized = false | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
| @ -64,13 +65,28 @@ class Server { | |||||||
|     Logger.info('[Server] Starting Scan') |     Logger.info('[Server] Starting Scan') | ||||||
|     this.isScanning = true |     this.isScanning = true | ||||||
|     this.isInitialized = true |     this.isInitialized = true | ||||||
|     this.emitter('scan_start') |     this.emitter('scan_start', 'files') | ||||||
|     var results = await this.scanner.scan() |     var results = await this.scanner.scan() | ||||||
|     this.isScanning = false |     this.isScanning = false | ||||||
|     this.emitter('scan_complete', results) |     this.emitter('scan_complete', { scanType: 'files', results }) | ||||||
|     Logger.info('[Server] Scan complete') |     Logger.info('[Server] Scan complete') | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async scanCovers() { | ||||||
|  |     Logger.info('[Server] Start cover scan') | ||||||
|  |     this.isScanningCovers = true | ||||||
|  |     this.emitter('scan_start', 'covers') | ||||||
|  |     var results = await this.scanner.scanCovers() | ||||||
|  |     this.isScanningCovers = false | ||||||
|  |     this.emitter('scan_complete', { scanType: 'covers', results }) | ||||||
|  |     Logger.info('[Server] Cover scan complete') | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   cancelScan() { | ||||||
|  |     if (!this.isScanningCovers && !this.isScanning) return | ||||||
|  |     this.scanner.cancelScan = true | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async init() { |   async init() { | ||||||
|     Logger.info('[Server] Init') |     Logger.info('[Server] Init') | ||||||
|     await this.streamManager.removeOrphanStreams() |     await this.streamManager.removeOrphanStreams() | ||||||
| @ -149,6 +165,8 @@ class Server { | |||||||
| 
 | 
 | ||||||
|       socket.on('auth', (token) => this.authenticateSocket(socket, token)) |       socket.on('auth', (token) => this.authenticateSocket(socket, token)) | ||||||
|       socket.on('scan', this.scan.bind(this)) |       socket.on('scan', this.scan.bind(this)) | ||||||
|  |       socket.on('scan_covers', this.scanCovers.bind(this)) | ||||||
|  |       socket.on('cancel_scan', this.cancelScan.bind(this)) | ||||||
|       socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) |       socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) | ||||||
|       socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) |       socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) | ||||||
|       socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) |       socket.on('stream_update', (payload) => this.streamManager.streamUpdate(socket, payload)) | ||||||
|  | |||||||
							
								
								
									
										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 Path = require('path') | ||||||
| const dir = require('node-dir') | const dir = require('node-dir') | ||||||
| const Logger = require('../Logger') | const Logger = require('../Logger') | ||||||
|  | const parseAuthors = require('./parseAuthors') | ||||||
| const { cleanString } = require('./index') | const { cleanString } = require('./index') | ||||||
| 
 | 
 | ||||||
| const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3'] | const AUDIOBOOK_PARTS_FORMATS = ['m4b', 'mp3'] | ||||||
| @ -74,6 +75,14 @@ async function getAllAudiobookFiles(abRootPath) { | |||||||
|         parts: [], |         parts: [], | ||||||
|         otherFiles: [] |         otherFiles: [] | ||||||
|       } |       } | ||||||
|  |       if (author) { | ||||||
|  |         var parsedAuthors = parseAuthors(author) | ||||||
|  |         if (parsedAuthors) { | ||||||
|  |           var { authorLF, authorFL } = parsedAuthors | ||||||
|  |           audiobooks[path].authorLF = authorLF || null | ||||||
|  |           audiobooks[path].authorFL = authorFL || null | ||||||
|  |         } | ||||||
|  |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     var filetype = getFileType(pathformat.ext) |     var filetype = getFileType(pathformat.ext) | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user