mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Lazy bookshelf
This commit is contained in:
		
							parent
							
								
									3941da1144
								
							
						
					
					
						commit
						4587916c8e
					
				| @ -4,6 +4,7 @@ | ||||
| 
 | ||||
| :root { | ||||
|   --bookshelf-texture-img: url(/textures/wood_default.jpg); | ||||
|   --bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); | ||||
| } | ||||
| 
 | ||||
| .page { | ||||
|  | ||||
| @ -442,17 +442,6 @@ export default { | ||||
| 
 | ||||
|     this.init() | ||||
|     this.initIO() | ||||
| 
 | ||||
|     setTimeout(() => { | ||||
|       var ids = {} | ||||
|       this.audiobooks.forEach((ab) => { | ||||
|         if (ids[ab.id]) { | ||||
|           console.error('FOUDN DUPLICATE ID', ids[ab.id], ab) | ||||
|         } else { | ||||
|           ids[ab.id] = ab | ||||
|         } | ||||
|       }) | ||||
|     }, 5000) | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     window.removeEventListener('resize', this.resize) | ||||
|  | ||||
							
								
								
									
										233
									
								
								client/components/app/LazyBookshelf.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								client/components/app/LazyBookshelf.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,233 @@ | ||||
| <template> | ||||
|   <div id="bookshelf" class="w-full overflow-y-auto"> | ||||
|     <template v-for="shelf in totalShelves"> | ||||
|       <div :key="shelf" class="w-full px-8 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }"> | ||||
|         <div class="absolute top-0 left-0 bottom-0 p-4 z-10"> | ||||
|           <p class="text-white text-2xl">{{ shelf }}</p> | ||||
|         </div> | ||||
|         <div class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-10" :class="`h-${shelfDividerHeightIndex}`" /> | ||||
|       </div> | ||||
|     </template> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Vue from 'vue' | ||||
| import LazyBookCard from '../cards/LazyBookCard' | ||||
| 
 | ||||
| export default { | ||||
|   data() { | ||||
|     return { | ||||
|       initialized: false, | ||||
|       bookshelfHeight: 0, | ||||
|       bookshelfWidth: 0, | ||||
|       shelvesPerPage: 0, | ||||
|       booksPerShelf: 8, | ||||
|       currentPage: 0, | ||||
|       totalBooks: 0, | ||||
|       books: [], | ||||
|       pagesLoaded: {}, | ||||
|       bookIndexesMounted: [], | ||||
|       bookComponentRefs: {}, | ||||
|       bookWidth: 120, | ||||
|       pageLoadQueue: [], | ||||
|       isFetchingBooks: false, | ||||
|       scrollTimeout: null, | ||||
|       booksPerFetch: 100, | ||||
|       totalShelves: 0, | ||||
|       bookshelfMarginLeft: 0 | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     sortBy() { | ||||
|       return this.$store.getters['user/getUserSetting']('orderBy') | ||||
|     }, | ||||
|     sortDesc() { | ||||
|       return this.$store.getters['user/getUserSetting']('orderDesc') | ||||
|     }, | ||||
|     filterBy() { | ||||
|       return this.$store.getters['user/getUserSetting']('filterBy') | ||||
|     }, | ||||
|     currentLibraryId() { | ||||
|       return this.$store.state.libraries.currentLibraryId | ||||
|     }, | ||||
|     bookHeight() { | ||||
|       return this.bookWidth * 1.6 | ||||
|     }, | ||||
|     shelfDividerHeightIndex() { | ||||
|       return 6 | ||||
|     }, | ||||
|     shelfHeight() { | ||||
|       return this.bookHeight + 40 | ||||
|     }, | ||||
|     totalBookCardWidth() { | ||||
|       // Includes margin | ||||
|       return this.bookWidth + 24 | ||||
|     }, | ||||
|     booksPerPage() { | ||||
|       return this.shelvesPerPage * this.booksPerShelf | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     async fetchBooks(page = 0) { | ||||
|       var startIndex = page * this.booksPerFetch | ||||
| 
 | ||||
|       this.isFetchingBooks = true | ||||
|       var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/books/all?sort=${this.sortBy}&desc=${this.sortDesc}&filter=${this.filterBy}&limit=${this.booksPerFetch}&page=${page}`).catch((error) => { | ||||
|         console.error('failed to fetch books', error) | ||||
|         return null | ||||
|       }) | ||||
|       if (payload) { | ||||
|         console.log('Received payload with start index', startIndex, 'pages loaded', this.pagesLoaded) | ||||
|         if (!this.initialized) { | ||||
|           this.initialized = true | ||||
|           this.totalBooks = payload.total | ||||
|           this.totalShelves = Math.ceil(this.totalBooks / this.booksPerShelf) | ||||
|           this.books = new Array(this.totalBooks) | ||||
|         } | ||||
| 
 | ||||
|         for (let i = 0; i < payload.results.length; i++) { | ||||
|           var bookIndex = i + startIndex | ||||
|           this.books[bookIndex] = payload.results[i] | ||||
| 
 | ||||
|           if (this.bookComponentRefs[bookIndex]) { | ||||
|             this.bookComponentRefs[bookIndex].setBook(this.books[bookIndex]) | ||||
|           } | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|     loadPage(page) { | ||||
|       this.pagesLoaded[page] = true | ||||
|       this.fetchBooks(page) | ||||
|     }, | ||||
|     async mountBookCard(index) { | ||||
|       var shelf = Math.floor(index / this.booksPerShelf) | ||||
|       var shelfEl = document.getElementById(`shelf-${shelf}`) | ||||
|       if (!shelfEl) { | ||||
|         console.error('invalid shelf', shelf) | ||||
|         return | ||||
|       } | ||||
|       this.bookIndexesMounted.push(index) | ||||
|       if (this.bookComponentRefs[index] && !this.bookIndexesMounted.includes(index)) { | ||||
|         shelfEl.appendChild(this.bookComponentRefs[index].$el) | ||||
|         return | ||||
|       } | ||||
| 
 | ||||
|       var shelfOffsetY = 16 | ||||
|       var row = index % this.booksPerShelf | ||||
|       var shelfOffsetX = row * this.totalBookCardWidth + this.bookshelfMarginLeft | ||||
| 
 | ||||
|       var ComponentClass = Vue.extend(LazyBookCard) | ||||
| 
 | ||||
|       var _this = this | ||||
|       var instance = new ComponentClass({ | ||||
|         propsData: { | ||||
|           index: index, | ||||
|           bookWidth: this.bookWidth | ||||
|         }, | ||||
|         created() { | ||||
|           // this.$on('action', (func) => { | ||||
|           //   if (_this[func]) _this[func]() | ||||
|           // }) | ||||
|         } | ||||
|       }) | ||||
|       this.bookComponentRefs[index] = instance | ||||
| 
 | ||||
|       instance.$mount() | ||||
|       instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)` | ||||
|       shelfEl.appendChild(instance.$el) | ||||
| 
 | ||||
|       if (this.books[index]) { | ||||
|         instance.setBook(this.books[index]) | ||||
|       } | ||||
|     }, | ||||
|     showHideBookPlaceholder(index, show) { | ||||
|       var el = document.getElementById(`book-${index}-placeholder`) | ||||
|       if (el) el.style.display = show ? 'flex' : 'none' | ||||
|     }, | ||||
|     unmountBookCard(index) { | ||||
|       if (this.bookComponentRefs[index]) { | ||||
|         this.bookComponentRefs[index].detach() | ||||
|       } | ||||
|     }, | ||||
|     mountBooks(fromIndex, toIndex) { | ||||
|       for (let i = fromIndex; i < toIndex; i++) { | ||||
|         this.mountBookCard(i) | ||||
|       } | ||||
|     }, | ||||
|     handleScroll(scrollTop) { | ||||
|       var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight) | ||||
|       var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight) | ||||
| 
 | ||||
|       var topShelfPage = Math.floor(firstShelfIndex / this.shelvesPerPage) | ||||
|       var bottomShelfPage = Math.floor(lastShelfIndex / this.shelvesPerPage) | ||||
|       if (!this.pagesLoaded[topShelfPage]) { | ||||
|         this.loadPage(topShelfPage) | ||||
|       } | ||||
|       if (!this.pagesLoaded[bottomShelfPage]) { | ||||
|         this.loadPage(bottomShelfPage) | ||||
|       } | ||||
|       console.log('Shelves in view', firstShelfIndex, 'to', lastShelfIndex) | ||||
| 
 | ||||
|       var firstBookIndex = firstShelfIndex * this.booksPerShelf | ||||
|       var lastBookIndex = lastShelfIndex * this.booksPerShelf + this.booksPerShelf | ||||
| 
 | ||||
|       this.bookIndexesMounted = this.bookIndexesMounted.filter((_index) => { | ||||
|         if (_index < firstBookIndex || _index >= lastBookIndex) { | ||||
|           var el = document.getElementById(`book-card-${_index}`) | ||||
|           if (el) el.remove() | ||||
|           return false | ||||
|         } | ||||
|         return true | ||||
|       }) | ||||
|       this.mountBooks(firstBookIndex, lastBookIndex) | ||||
|     }, | ||||
|     scroll(e) { | ||||
|       if (!e || !e.target) return | ||||
|       var { scrollTop } = e.target | ||||
|       // clearTimeout(this.scrollTimeout) | ||||
|       // this.scrollTimeout = setTimeout(() => { | ||||
|       this.handleScroll(scrollTop) | ||||
|       // }, 250) | ||||
|     }, | ||||
|     async init(bookshelf) { | ||||
|       var { clientHeight, clientWidth } = bookshelf | ||||
|       this.bookshelfHeight = clientHeight | ||||
|       this.bookshelfWidth = clientWidth | ||||
|       this.booksPerShelf = Math.floor((this.bookshelfWidth - 64) / this.totalBookCardWidth) | ||||
|       this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2 | ||||
|       this.bookshelfMarginLeft = (this.bookshelfWidth - this.booksPerShelf * this.totalBookCardWidth) / 2 | ||||
|       console.log('Shelves per page', this.shelvesPerPage, 'books per page =', this.booksPerShelf * this.shelvesPerPage) | ||||
| 
 | ||||
|       this.pagesLoaded[0] = true | ||||
|       await this.fetchBooks(0) | ||||
|       var lastBookIndex = this.shelvesPerPage * this.booksPerShelf | ||||
|       this.mountBooks(0, lastBookIndex) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     var bookshelf = document.getElementById('bookshelf') | ||||
|     if (bookshelf) { | ||||
|       this.init(bookshelf) | ||||
|       bookshelf.addEventListener('scroll', this.scroll) | ||||
|     } | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     var bookshelf = document.getElementById('bookshelf') | ||||
|     if (bookshelf) { | ||||
|       bookshelf.removeEventListener('scroll', this.scroll) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| 
 | ||||
| <style> | ||||
| .bookshelfRow { | ||||
|   background-image: var(--bookshelf-texture-img); | ||||
| } | ||||
| .bookshelfDivider { | ||||
|   background: rgb(149, 119, 90); | ||||
|   background: var(--bookshelf-divider-bg); | ||||
|   box-shadow: 2px 14px 8px #111111aa; | ||||
| } | ||||
| </style> | ||||
							
								
								
									
										302
									
								
								client/components/cards/LazyBookCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								client/components/cards/LazyBookCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,302 @@ | ||||
| <template> | ||||
|   <div ref="card" :id="`book-card-${index}`" :style="{ width: bookWidth + 'px', height: bookHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-20"> | ||||
|     <div class="w-full h-full bg-primary relative rounded-sm"> | ||||
|       <div class="absolute top-0 left-0 w-full flex items-center justify-center"> | ||||
|         <p>{{ title }}/{{ index }}</p> | ||||
|       </div> | ||||
|       <img v-show="audiobook" :src="bookCoverSrc" class="w-full h-full object-contain" /> | ||||
|       <!-- <covers-book-cover v-show="audiobook" :audiobook="audiobook" :width="bookWidth" /> --> | ||||
|     </div> | ||||
| 
 | ||||
|     <!-- <div ref="overlay-wrapper" class="w-full h-full relative box-shadow-book cursor-pointer" @click="clickCard" @mouseover="mouseover" @mouseleave="isHovering = false"> | ||||
|       <covers-book-cover :audiobook="audiobook" :width="bookWidth" /> | ||||
|       <div v-if="false" ref="overlay"> | ||||
|         <div v-show="isHovering || isSelectionMode || isMoreMenuOpen" class="absolute top-0 left-0 w-full h-full bg-black rounded hidden md:block z-20" :class="overlayWrapperClasslist"> | ||||
|           <div v-show="showPlayButton" class="h-full flex items-center justify-center"> | ||||
|             <div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play"> | ||||
|               <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div v-show="showReadButton" class="h-full flex items-center justify-center"> | ||||
|             <div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook"> | ||||
|               <span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick"> | ||||
|             <span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span> | ||||
|           </div> | ||||
| 
 | ||||
|           <div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick"> | ||||
|             <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> | ||||
|           </div> | ||||
|           <div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore"> | ||||
|             <span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
|         <div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }"> | ||||
|           <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p> | ||||
|         </div> | ||||
|         <div | ||||
|           v-if="showSmallEBookIcon" | ||||
|           class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200" | ||||
|           :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }" | ||||
|           @click.stop.prevent="clickReadEBook" | ||||
|         > | ||||
|           <span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span> | ||||
|         </div> | ||||
| 
 | ||||
|         <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: bookWidth * userProgressPercent + 'px' }"></div> | ||||
| 
 | ||||
|         <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> | ||||
|           <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> | ||||
|             <span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span> | ||||
|           </div> | ||||
|         </ui-tooltip> | ||||
|       </div> | ||||
|     </div> --> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     index: Number, | ||||
|     bookWidth: { | ||||
|       type: Number, | ||||
|       default: 120 | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       isAttached: false, | ||||
|       isHovering: false, | ||||
|       isMoreMenuOpen: false, | ||||
|       isProcessingReadUpdate: false, | ||||
|       overlayEl: null, | ||||
|       audiobook: null | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     _audiobook() { | ||||
|       return this.audiobook || {} | ||||
|     }, | ||||
|     bookCoverSrc() { | ||||
|       return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl) | ||||
|     }, | ||||
|     audiobookId() { | ||||
|       return this._audiobook.id | ||||
|     }, | ||||
|     hasEbook() { | ||||
|       return this._audiobook.numEbooks | ||||
|     }, | ||||
|     hasTracks() { | ||||
|       return this._audiobook.numTracks | ||||
|     }, | ||||
|     isSelectionMode() { | ||||
|       return !!this.selectedAudiobooks.length | ||||
|     }, | ||||
|     selectedAudiobooks() { | ||||
|       return this.store.state.selectedAudiobooks | ||||
|     }, | ||||
|     selected() { | ||||
|       return this.store.getters['getIsAudiobookSelected'](this.audiobookId) | ||||
|     }, | ||||
|     processingBatch() { | ||||
|       return this.store.state.processingBatch | ||||
|     }, | ||||
|     book() { | ||||
|       return this._audiobook.book || {} | ||||
|     }, | ||||
|     bookHeight() { | ||||
|       return this.bookWidth * 1.6 | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       return this.bookWidth / 120 | ||||
|     }, | ||||
|     title() { | ||||
|       return this.book.title | ||||
|     }, | ||||
|     playIconFontSize() { | ||||
|       return Math.max(2, 3 * this.sizeMultiplier) | ||||
|     }, | ||||
|     author() { | ||||
|       return this.book.author | ||||
|     }, | ||||
|     authorFL() { | ||||
|       return this.book.authorFL || this.author | ||||
|     }, | ||||
|     authorLF() { | ||||
|       return this.book.authorLF || this.author | ||||
|     }, | ||||
|     volumeNumber() { | ||||
|       return this.book.volumeNumber || null | ||||
|     }, | ||||
|     userProgress() { | ||||
|       var store = this.$store || this.$nuxt.$store | ||||
|       return store.getters['user/getUserAudiobook'](this.audiobookId) | ||||
|     }, | ||||
|     userProgressPercent() { | ||||
|       return this.userProgress ? this.userProgress.progress || 0 : 0 | ||||
|     }, | ||||
|     userIsRead() { | ||||
|       return this.userProgress ? !!this.userProgress.isRead : false | ||||
|     }, | ||||
|     showError() { | ||||
|       return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete | ||||
|     }, | ||||
|     isStreaming() { | ||||
|       var store = this.$store || this.$nuxt.$store | ||||
|       return store.getters['getAudiobookIdStreaming'] === this.audiobookId | ||||
|     }, | ||||
|     showReadButton() { | ||||
|       return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook | ||||
|     }, | ||||
|     showPlayButton() { | ||||
|       return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming | ||||
|     }, | ||||
|     showSmallEBookIcon() { | ||||
|       return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|     }, | ||||
|     isIncomplete() { | ||||
|       return this.audiobook.isIncomplete | ||||
|     }, | ||||
|     hasMissingParts() { | ||||
|       return this.audiobook.hasMissingParts | ||||
|     }, | ||||
|     hasInvalidParts() { | ||||
|       return this.audiobook.hasInvalidParts | ||||
|     }, | ||||
|     errorText() { | ||||
|       if (this.isMissing) return 'Audiobook directory is missing!' | ||||
|       else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook' | ||||
|       var txt = '' | ||||
|       if (this.hasMissingParts) { | ||||
|         txt = `${this.hasMissingParts} missing parts.` | ||||
|       } | ||||
|       if (this.hasInvalidParts) { | ||||
|         if (this.hasMissingParts) txt += ' ' | ||||
|         txt += `${this.hasInvalidParts} invalid parts.` | ||||
|       } | ||||
|       return txt || 'Unknown Error' | ||||
|     }, | ||||
|     overlayWrapperClasslist() { | ||||
|       var classes = [] | ||||
|       if (this.isSelectionMode) classes.push('bg-opacity-60') | ||||
|       else classes.push('bg-opacity-40') | ||||
|       if (this.selected) { | ||||
|         classes.push('border-2 border-yellow-400') | ||||
|       } | ||||
|       return classes | ||||
|     }, | ||||
|     store() { | ||||
|       return this.$store || this.$nuxt.$store | ||||
|     }, | ||||
|     userCanUpdate() { | ||||
|       return this.store.getters['user/getUserCanUpdate'] | ||||
|     }, | ||||
|     userCanDelete() { | ||||
|       return this.store.getters['user/getUserCanDelete'] | ||||
|     }, | ||||
|     userCanDownload() { | ||||
|       return this.store.getters['user/getUserCanDownload'] | ||||
|     }, | ||||
|     userIsRoot() { | ||||
|       return this.store.getters['user/getIsRoot'] | ||||
|     }, | ||||
|     moreMenuItems() { | ||||
|       var items = [ | ||||
|         { | ||||
|           func: 'toggleRead', | ||||
|           text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}` | ||||
|         }, | ||||
|         { | ||||
|           func: 'openCollections', | ||||
|           text: 'Add to Collection' | ||||
|         } | ||||
|       ] | ||||
|       if (this.userCanUpdate) { | ||||
|         if (this.hasTracks) { | ||||
|           items.push({ | ||||
|             func: 'showEditModalTracks', | ||||
|             text: 'Tracks' | ||||
|           }) | ||||
|         } | ||||
|         items.push({ | ||||
|           func: 'showEditModalMatch', | ||||
|           text: 'Match' | ||||
|         }) | ||||
|       } | ||||
|       if (this.userCanDownload) { | ||||
|         items.push({ | ||||
|           func: 'showEditModalDownload', | ||||
|           text: 'Download' | ||||
|         }) | ||||
|       } | ||||
|       if (this.userIsRoot) { | ||||
|         items.push({ | ||||
|           func: 'rescan', | ||||
|           text: 'Re-Scan' | ||||
|         }) | ||||
|       } | ||||
|       return items | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setBook(audiobook) { | ||||
|       this.audiobook = audiobook | ||||
|     }, | ||||
|     clickCard(e) { | ||||
|       if (this.isSelectionMode) { | ||||
|         e.stopPropagation() | ||||
|         e.preventDefault() | ||||
|         this.selectBtnClick() | ||||
|       } | ||||
|     }, | ||||
|     clickShowMore() {}, | ||||
|     clickReadEBook() {}, | ||||
|     editBtnClick() {}, | ||||
|     selectBtnClick() { | ||||
|       if (this.processingBatch) return | ||||
|       this.store.commit('toggleAudiobookSelected', this.audiobookId) | ||||
|     }, | ||||
|     play() {}, | ||||
|     detach() { | ||||
|       if (!this.isAttached) return | ||||
|       if (this.$refs.overlay) { | ||||
|         this.overlayEl = this.$refs.overlay | ||||
|         this.overlayEl.remove() | ||||
|       } else if (this.overlayEl) { | ||||
|         this.overlayEl.remove() | ||||
|       } | ||||
|       this.isAttached = false | ||||
|     }, | ||||
|     attach() { | ||||
|       if (this.isAttached) return | ||||
|       this.isAttached = true | ||||
| 
 | ||||
|       if (this.overlayEl) { | ||||
|         this.$refs['overlay-wrapper'].appendChild(this.overlayEl) | ||||
|       } | ||||
|     }, | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|     }, | ||||
|     // mouseleave() { | ||||
|     //   this.isHovering = false | ||||
|     // }, | ||||
|     destroy() { | ||||
|       // destroy the vue listeners, etc | ||||
|       this.$destroy() | ||||
| 
 | ||||
|       // remove the element from the DOM | ||||
|       this.$el.parentNode.removeChild(this.$el) | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| } | ||||
| </script> | ||||
| @ -4,8 +4,8 @@ | ||||
|       <div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full"> | ||||
|         <div class="w-full h-full z-0" ref="coverBg" /> | ||||
|       </div> | ||||
|       <img ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> | ||||
|       <div v-show="loading" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> | ||||
|       <img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> | ||||
|       <div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> | ||||
|         <p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p> | ||||
|         <div class="absolute top-2 right-2"> | ||||
|           <div class="la-ball-spin-clockwise la-sm"> | ||||
| @ -67,6 +67,7 @@ export default { | ||||
|   }, | ||||
|   computed: { | ||||
|     book() { | ||||
|       if (!this.audiobook) return {} | ||||
|       return this.audiobook.book || {} | ||||
|     }, | ||||
|     title() { | ||||
| @ -92,7 +93,9 @@ export default { | ||||
|       return '/book_placeholder.jpg' | ||||
|     }, | ||||
|     fullCoverUrl() { | ||||
|       return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) | ||||
|       if (!this.audiobook) return null | ||||
|       var store = this.$store || this.$nuxt.$store | ||||
|       return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) | ||||
|     }, | ||||
|     cover() { | ||||
|       return this.book.cover || this.placeholderUrl | ||||
|  | ||||
| @ -4,7 +4,8 @@ | ||||
|       <app-side-rail class="hidden md:block" /> | ||||
|       <div class="flex-grow"> | ||||
|         <app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" /> | ||||
|         <app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" /> | ||||
|         <app-lazy-bookshelf /> | ||||
|         <!-- <app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" /> --> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
|  | ||||
							
								
								
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @ -1,6 +1,6 @@ | ||||
| { | ||||
|   "name": "audiobookshelf", | ||||
|   "version": "1.6.29", | ||||
|   "version": "1.6.30", | ||||
|   "lockfileVersion": 1, | ||||
|   "requires": true, | ||||
|   "dependencies": { | ||||
| @ -673,6 +673,11 @@ | ||||
|       "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz", | ||||
|       "integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew==" | ||||
|     }, | ||||
|     "fast-sort": { | ||||
|       "version": "3.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/fast-sort/-/fast-sort-3.1.1.tgz", | ||||
|       "integrity": "sha512-EA3PVIYj8uyyJc2Mma7GHjMrE74N/ClKkBj5gVUmY+8JePrc/ognCk4bhszVGYazu9Qk2aUTHnBF38QDSHcjkg==" | ||||
|     }, | ||||
|     "file-type": { | ||||
|       "version": "10.11.0", | ||||
|       "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", | ||||
|  | ||||
| @ -32,6 +32,7 @@ | ||||
|     "express": "^4.17.1", | ||||
|     "express-fileupload": "^1.2.1", | ||||
|     "express-rate-limit": "^5.3.0", | ||||
|     "fast-sort": "^3.1.1", | ||||
|     "fluent-ffmpeg": "^2.1.2", | ||||
|     "fs-extra": "^10.0.0", | ||||
|     "image-type": "^4.1.0", | ||||
| @ -50,4 +51,4 @@ | ||||
|     "xml2js": "^0.4.23" | ||||
|   }, | ||||
|   "devDependencies": {} | ||||
| } | ||||
| } | ||||
|  | ||||
| @ -53,6 +53,7 @@ class ApiController { | ||||
|     this.router.patch('/libraries/:id', LibraryController.update.bind(this)) | ||||
|     this.router.delete('/libraries/:id', LibraryController.delete.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/libraries/:id/books/all', LibraryController.getBooksForLibrary2.bind(this)) | ||||
|     this.router.get('/libraries/:id/books', LibraryController.getBooksForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/search', LibraryController.search.bind(this)) | ||||
|     this.router.patch('/libraries/order', LibraryController.reorder.bind(this)) | ||||
| @ -488,5 +489,45 @@ class ApiController { | ||||
|     }) | ||||
|     return listeningStats | ||||
|   } | ||||
| 
 | ||||
| 
 | ||||
|   decode(text) { | ||||
|     return Buffer.from(decodeURIComponent(text), 'base64').toString() | ||||
|   } | ||||
| 
 | ||||
|   getFiltered(audiobooks, filterBy, user) { | ||||
|     var filtered = audiobooks | ||||
| 
 | ||||
|     var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators'] | ||||
|     var group = searchGroups.find(_group => filterBy.startsWith(_group + '.')) | ||||
|     if (group) { | ||||
|       var filterVal = filterBy.replace(`${group}.`, '') | ||||
|       var filter = this.decode(filterVal) | ||||
|       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') { | ||||
|         if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series) | ||||
|         else filtered = filtered.filter(ab => ab.book && ab.book.series === filter) | ||||
|       } | ||||
|       else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter)) | ||||
|       else if (group === 'progress') { | ||||
|         filtered = filtered.filter(ab => { | ||||
|           var userAudiobook = user.getAudiobookJSON(ab.id) | ||||
|           var isRead = userAudiobook && userAudiobook.isRead | ||||
|           if (filter === 'Read' && isRead) return true | ||||
|           if (filter === 'Unread' && !isRead) return true | ||||
|           if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true | ||||
|           return false | ||||
|         }) | ||||
|       } | ||||
|     } else if (filterBy === 'issues') { | ||||
|       filtered = filtered.filter(ab => { | ||||
|         return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     return filtered | ||||
|   } | ||||
| } | ||||
| module.exports = ApiController | ||||
| @ -1,16 +1,11 @@ | ||||
| const Logger = require('../Logger') | ||||
| 
 | ||||
| class BookController { | ||||
|   constructor(db, emitter, clientEmitter, streamManager, coverController) { | ||||
|     this.db = db | ||||
|     this.emitter = emitter | ||||
|     this.clientEmitter = clientEmitter | ||||
|     this.streamManager = streamManager | ||||
|     this.coverController = coverController | ||||
|   } | ||||
|   constructor() { } | ||||
| 
 | ||||
|   findAll(req, res) { | ||||
|     var audiobooks = [] | ||||
| 
 | ||||
|     if (req.query.q) { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => { | ||||
|         return ab.isSearchMatch(req.query.q) | ||||
|  | ||||
| @ -1,5 +1,6 @@ | ||||
| const Logger = require('../Logger') | ||||
| const Library = require('../objects/Library') | ||||
| const { sort } = require('fast-sort') | ||||
| 
 | ||||
| class LibraryController { | ||||
|   constructor() { } | ||||
| @ -91,18 +92,84 @@ class LibraryController { | ||||
|     if (!library) { | ||||
|       return res.status(400).send('Library does not exist') | ||||
|     } | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
|     // if (req.query.q) {
 | ||||
|     //   audiobooks = this.db.audiobooks.filter(ab => {
 | ||||
|     //     return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
 | ||||
|     //   }).map(ab => ab.toJSONMinified())
 | ||||
|     // } else {
 | ||||
|     //   audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
 | ||||
|     // }
 | ||||
| 
 | ||||
|     var audiobooks = [] | ||||
|     if (req.query.q) { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => { | ||||
|         return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q) | ||||
|       }).map(ab => ab.toJSONMinified()) | ||||
|     } else { | ||||
|       audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified()) | ||||
|     if (req.query.filter) { | ||||
|       audiobooks = this.getFiltered(this.db.audiobooks, req.query.filter, req.user) | ||||
|     } | ||||
| 
 | ||||
| 
 | ||||
|     if (req.query.sort) { | ||||
|       var orderByNumber = req.query.sort === 'book.volumeNumber' | ||||
|       var direction = req.query.desc === '1' ? 'desc' : 'asc' | ||||
|       audiobooks = sort(audiobooks)[direction]((ab) => { | ||||
|         // Supports dot notation strings i.e. "book.title"
 | ||||
|         var value = req.query.sort.split('.').reduce((a, b) => a[b], ab) | ||||
|         if (orderByNumber && !isNaN(value)) return Number(value) | ||||
|         return value | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     if (req.query.limit && !isNaN(req.query.limit)) { | ||||
|       var page = req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0 | ||||
|       var limit = Number(req.query.limit) | ||||
|       var startIndex = page * limit | ||||
|       audiobooks = audiobooks.slice(startIndex, startIndex + limit) | ||||
|     } | ||||
|     res.json(audiobooks) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/fs
 | ||||
|   getBooksForLibrary2(req, res) { | ||||
|     var libraryId = req.params.id | ||||
|     var library = this.db.libraries.find(lib => lib.id === libraryId) | ||||
|     if (!library) { | ||||
|       return res.status(400).send('Library does not exist') | ||||
|     } | ||||
| 
 | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
|     var payload = { | ||||
|       results: [], | ||||
|       total: audiobooks.length, | ||||
|       limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, | ||||
|       page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, | ||||
|       sortBy: req.query.sort, | ||||
|       sortDesc: req.query.desc === '1', | ||||
|       filterBy: req.query.filter | ||||
|     } | ||||
| 
 | ||||
|     if (payload.filterBy) { | ||||
|       audiobooks = this.getFiltered(this.db.audiobooks, payload.filterBy, req.user) | ||||
|     } | ||||
| 
 | ||||
|     if (payload.sortBy) { | ||||
|       var orderByNumber = payload.sortBy === 'book.volumeNumber' | ||||
|       var direction = payload.sortDesc ? 'desc' : 'asc' | ||||
|       audiobooks = sort(audiobooks)[direction]((ab) => { | ||||
|         // Supports dot notation strings i.e. "book.title"
 | ||||
|         var value = payload.sortBy.split('.').reduce((a, b) => a[b], ab) | ||||
|         if (orderByNumber && !isNaN(value)) return Number(value) | ||||
|         return value | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       var startIndex = payload.page * payload.limit | ||||
|       audiobooks = audiobooks.slice(startIndex, startIndex + payload.limit) | ||||
|     } | ||||
|     payload.results = audiobooks.map(ab => ab.toJSONExpanded()) | ||||
|     console.log('returning books', audiobooks.length) | ||||
| 
 | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // PATCH: Change the order of libraries
 | ||||
|   async reorder(req, res) { | ||||
|     if (!req.user.isRoot) { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user