mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	New api routes, updating web client pages, audiobooks to libraryItem migration
This commit is contained in:
		
							parent
							
								
									b97ed953f7
								
							
						
					
					
						commit
						2a30cc428f
					
				| @ -85,8 +85,9 @@ export default { | ||||
|     }, | ||||
|     async fetchCategories() { | ||||
|       var categories = await this.$axios | ||||
|         .$get(`/api/libraries/${this.currentLibraryId}/categories?minified=1`) | ||||
|         .$get(`/api/libraries/${this.currentLibraryId}/personalized?minified=1`) | ||||
|         .then((data) => { | ||||
|           console.log('Personalized data', data) | ||||
|           return data | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|  | ||||
| @ -239,7 +239,7 @@ export default { | ||||
|         this.currentSFQueryString = this.buildSearchParams() | ||||
|       } | ||||
| 
 | ||||
|       var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `books/all` : this.entityName | ||||
|       var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName | ||||
|       var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' | ||||
|       var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1` | ||||
| 
 | ||||
|  | ||||
| @ -8,7 +8,7 @@ | ||||
|     <!-- Alternative bookshelf title/author/sort --> | ||||
|     <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }"> | ||||
|       <p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }"> | ||||
|         <span v-if="volumeNumber">#{{ volumeNumber }} </span>{{ displayTitle }} | ||||
|         {{ displayTitle }} | ||||
|       </p> | ||||
|       <p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p> | ||||
|       <p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p> | ||||
| @ -21,7 +21,7 @@ | ||||
|         <p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p> | ||||
|       </div> | ||||
| 
 | ||||
|       <img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> | ||||
|       <img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" /> | ||||
| 
 | ||||
|       <!-- Placeholder Cover Title & Author --> | ||||
|       <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' }"> | ||||
| @ -136,42 +136,45 @@ export default { | ||||
|     showExperimentalFeatures() { | ||||
|       return this.store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     _audiobook() { | ||||
|     _libraryItem() { | ||||
|       return this.audiobook || {} | ||||
|     }, | ||||
|     book() { | ||||
|       return this._audiobook.book || {} | ||||
|     media() { | ||||
|       return this._libraryItem.media || {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     placeholderUrl() { | ||||
|       return '/book_placeholder.jpg' | ||||
|     }, | ||||
|     bookCoverSrc() { | ||||
|       return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl) | ||||
|       return this.store.getters['audiobooks/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl) | ||||
|     }, | ||||
|     audiobookId() { | ||||
|       return this._audiobook.id | ||||
|     libraryItemId() { | ||||
|       return this._libraryItem.id | ||||
|     }, | ||||
|     series() { | ||||
|       return this.book.series | ||||
|       return this.mediaMetadata.series | ||||
|     }, | ||||
|     libraryId() { | ||||
|       return this._audiobook.libraryId | ||||
|       return this._libraryItem.libraryId | ||||
|     }, | ||||
|     hasEbook() { | ||||
|       return this._audiobook.numEbooks | ||||
|       return this.media.numEbooks | ||||
|     }, | ||||
|     hasTracks() { | ||||
|       return this._audiobook.numTracks | ||||
|       return this.media.numTracks | ||||
|     }, | ||||
|     processingBatch() { | ||||
|       return this.store.state.processingBatch | ||||
|     }, | ||||
|     booksInSeries() { | ||||
|       // Only added to audiobook object when collapseSeries is enabled | ||||
|       return this._audiobook.booksInSeries | ||||
|       return this._libraryItem.booksInSeries | ||||
|     }, | ||||
|     hasCover() { | ||||
|       return !!this.book.cover | ||||
|       return !!this.media.coverPath | ||||
|     }, | ||||
|     squareAspectRatio() { | ||||
|       return this.bookCoverAspectRatio === 1 | ||||
| @ -181,43 +184,49 @@ export default { | ||||
|       return this.width / baseSize | ||||
|     }, | ||||
|     title() { | ||||
|       return this.book.title || '' | ||||
|       return this.mediaMetadata.title || '' | ||||
|     }, | ||||
|     playIconFontSize() { | ||||
|       return Math.max(2, 3 * this.sizeMultiplier) | ||||
|     }, | ||||
|     author() { | ||||
|       return this.book.author | ||||
|     authors() { | ||||
|       return this.mediaMetadata.authors || [] | ||||
|     }, | ||||
|     authorFL() { | ||||
|       return this.book.authorFL || this.author | ||||
|     author() { | ||||
|       return this.authors.map((au) => au.name).join(', ') | ||||
|     }, | ||||
|     authorLF() { | ||||
|       return this.book.authorLF || this.author | ||||
|       return this.authors | ||||
|         .map((au) => { | ||||
|           var parts = au.name.split(' ') | ||||
|           if (parts.length === 1) return parts[0] | ||||
|           return `${parts[1]}, ${parts[0]}` | ||||
|         }) | ||||
|         .join(', ') | ||||
|     }, | ||||
|     volumeNumber() { | ||||
|       return this.book.volumeNumber || null | ||||
|       return this.mediaMetadata.volumeNumber || null | ||||
|     }, | ||||
|     displayTitle() { | ||||
|       if (this.orderBy === 'book.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { | ||||
|       if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) { | ||||
|         return this.title.substr(4) + ', The' | ||||
|       } | ||||
|       return this.title | ||||
|     }, | ||||
|     displayAuthor() { | ||||
|       if (this.orderBy === 'book.authorLF') return this.authorLF | ||||
|       return this.authorFL | ||||
|       if (this.orderBy === 'media.metadata.authorLF') return this.authorLF | ||||
|       return this.author | ||||
|     }, | ||||
|     displaySortLine() { | ||||
|       if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._audiobook.mtimeMs) | ||||
|       if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._audiobook.birthtimeMs) | ||||
|       if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._audiobook.addedAt) | ||||
|       if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this._audiobook.duration, false) | ||||
|       if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._audiobook.size) | ||||
|       if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs) | ||||
|       if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs) | ||||
|       if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt) | ||||
|       if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false) | ||||
|       if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size) | ||||
|       return null | ||||
|     }, | ||||
|     userProgress() { | ||||
|       return this.store.getters['user/getUserAudiobook'](this.audiobookId) | ||||
|       return this.store.getters['user/getUserAudiobook'](this.libraryItemId) | ||||
|     }, | ||||
|     userProgressPercent() { | ||||
|       return this.userProgress ? this.userProgress.progress || 0 : 0 | ||||
| @ -229,7 +238,7 @@ export default { | ||||
|       return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid | ||||
|     }, | ||||
|     isStreaming() { | ||||
|       return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId | ||||
|       return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId | ||||
|     }, | ||||
|     showReadButton() { | ||||
|       return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook | ||||
| @ -241,16 +250,16 @@ export default { | ||||
|       return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this._audiobook.isMissing | ||||
|       return this._libraryItem.isMissing | ||||
|     }, | ||||
|     isInvalid() { | ||||
|       return this._audiobook.isInvalid | ||||
|       return this._libraryItem.isInvalid | ||||
|     }, | ||||
|     hasMissingParts() { | ||||
|       return this._audiobook.hasMissingParts | ||||
|       return this._libraryItem.hasMissingParts | ||||
|     }, | ||||
|     hasInvalidParts() { | ||||
|       return this._audiobook.hasInvalidParts | ||||
|       return this._libraryItem.hasInvalidParts | ||||
|     }, | ||||
|     errorText() { | ||||
|       if (this.isMissing) return 'Audiobook directory is missing!' | ||||
| @ -349,11 +358,11 @@ export default { | ||||
|       return this.title | ||||
|     }, | ||||
|     authorCleaned() { | ||||
|       if (!this.authorFL) return '' | ||||
|       if (this.authorFL.length > 30) { | ||||
|         return this.authorFL.slice(0, 27) + '...' | ||||
|       if (!this.author) return '' | ||||
|       if (this.author.length > 30) { | ||||
|         return this.author.slice(0, 27) + '...' | ||||
|       } | ||||
|       return this.authorFL | ||||
|       return this.author | ||||
|     }, | ||||
|     isAlternativeBookshelfView() { | ||||
|       var constants = this.$constants || this.$nuxt.$constants | ||||
| @ -382,7 +391,7 @@ export default { | ||||
|         var router = this.$router || this.$nuxt.$router | ||||
|         if (router) { | ||||
|           if (this.booksInSeries) router.push(`/library/${this.libraryId}/series/${this.$encode(this.series)}`) | ||||
|           else router.push(`/audiobook/${this.audiobookId}`) | ||||
|           else router.push(`/item/${this.libraryItemId}`) | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
| @ -398,7 +407,7 @@ export default { | ||||
|       var toast = this.$toast || this.$nuxt.$toast | ||||
|       var axios = this.$axios || this.$nuxt.$axios | ||||
|       axios | ||||
|         .$patch(`/api/me/audiobook/${this.audiobookId}`, updatePayload) | ||||
|         .$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
| @ -425,7 +434,7 @@ export default { | ||||
|     rescan() { | ||||
|       this.rescanning = true | ||||
|       this._socket.once('audiobook_scan_complete', this.audiobookScanComplete) | ||||
|       this._socket.emit('scan_audiobook', this.audiobookId) | ||||
|       this._socket.emit('scan_libraryItem', this.libraryItemId) | ||||
|     }, | ||||
|     showEditModalTracks() { | ||||
|       // More menu func | ||||
| @ -503,7 +512,7 @@ export default { | ||||
|     }, | ||||
|     play() { | ||||
|       var eventBus = this.$eventBus || this.$nuxt.$eventBus | ||||
|       eventBus.$emit('play-audiobook', this.audiobookId) | ||||
|       eventBus.$emit('play-audiobook', this.libraryItemId) | ||||
|     }, | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|  | ||||
| @ -34,27 +34,23 @@ export default { | ||||
|       items: [ | ||||
|         { | ||||
|           text: 'Title', | ||||
|           value: 'book.title' | ||||
|           value: 'media.metadata.title' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Author (First Last)', | ||||
|           value: 'book.authorFL' | ||||
|           value: 'media.metadata.author' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Author (Last, First)', | ||||
|           value: 'book.authorLF' | ||||
|           value: 'media.metadata.authorLF' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Added At', | ||||
|           value: 'addedAt' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Volume #', | ||||
|           value: 'book.volumeNumber' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Duration', | ||||
|           value: 'duration' | ||||
|           value: 'media.duration' | ||||
|         }, | ||||
|         { | ||||
|           text: 'Size', | ||||
| @ -89,7 +85,8 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     selectedText() { | ||||
|       var _selected = this.selected === 'book.author' ? 'book.authorFL' : this.selected | ||||
|       var _selected = this.selected | ||||
|       if (this.selected.startsWith('book.')) _selected = _selected.replace('book.', 'media.metadata.') | ||||
|       var _sel = this.items.find((i) => i.value === _selected) | ||||
|       if (!_sel) return '' | ||||
|       return _sel.text | ||||
|  | ||||
| @ -5,8 +5,8 @@ | ||||
|         <div class="absolute cover-bg" ref="coverBg" /> | ||||
|       </div> | ||||
| 
 | ||||
|       <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 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" /> | ||||
|       <div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center"> | ||||
|       <img v-if="libraryItem" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" /> | ||||
|       <div v-show="loading && libraryItem" 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"> | ||||
| @ -44,11 +44,10 @@ | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     }, | ||||
|     authorOverride: String, | ||||
|     width: { | ||||
|       type: Number, | ||||
|       default: 120 | ||||
| @ -75,12 +74,15 @@ export default { | ||||
|     height() { | ||||
|       return this.width * this.bookCoverAspectRatio | ||||
|     }, | ||||
|     book() { | ||||
|       if (!this.audiobook) return {} | ||||
|       return this.audiobook.book || {} | ||||
|     media() { | ||||
|       if (!this.libraryItem) return {} | ||||
|       return this.libraryItem.media || {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     title() { | ||||
|       return this.book.title || 'No Title' | ||||
|       return this.mediaMetadata.title || 'No Title' | ||||
|     }, | ||||
|     titleCleaned() { | ||||
|       if (this.title.length > 60) { | ||||
| @ -88,9 +90,11 @@ export default { | ||||
|       } | ||||
|       return this.title | ||||
|     }, | ||||
|     authors() { | ||||
|       return this.mediaMetadata.authors || [] | ||||
|     }, | ||||
|     author() { | ||||
|       if (this.authorOverride) return this.authorOverride | ||||
|       return this.book.author || 'Unknown' | ||||
|       return this.authors.map((au) => au.name).join(', ') | ||||
|     }, | ||||
|     authorCleaned() { | ||||
|       if (this.author.length > 30) { | ||||
| @ -102,15 +106,15 @@ export default { | ||||
|       return '/book_placeholder.jpg' | ||||
|     }, | ||||
|     fullCoverUrl() { | ||||
|       if (!this.audiobook) return null | ||||
|       if (!this.libraryItem) return null | ||||
|       var store = this.$store || this.$nuxt.$store | ||||
|       return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) | ||||
|       return store.getters['audiobooks/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl) | ||||
|     }, | ||||
|     cover() { | ||||
|       return this.book.cover || this.placeholderUrl | ||||
|       return this.media.coverPath || this.placeholderUrl | ||||
|     }, | ||||
|     hasCover() { | ||||
|       return !!this.book.cover | ||||
|       return !!this.media.coverPath | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       var baseSize = this.squareAspectRatio ? 192 : 120 | ||||
| @ -138,7 +142,6 @@ export default { | ||||
|         this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")` | ||||
|       } | ||||
|     }, | ||||
|     hideCoverBg() {}, | ||||
|     imageLoaded() { | ||||
|       this.loading = false | ||||
|       this.$nextTick(() => { | ||||
|  | ||||
| @ -63,7 +63,7 @@ export default { | ||||
|   }, | ||||
|   methods: { | ||||
|     getCoverUrl(book) { | ||||
|       return this.store.getters['audiobooks/getBookCoverSrc'](book, '') | ||||
|       return this.store.getters['audiobooks/getLibraryItemCoverSrc'](book, '') | ||||
|     }, | ||||
|     async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) { | ||||
|       var src = coverData.coverUrl | ||||
|  | ||||
| @ -22,7 +22,7 @@ export default { | ||||
|       return '/book_placeholder.jpg' | ||||
|     }, | ||||
|     fullCoverUrl() { | ||||
|       return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl) | ||||
|       return this.$store.getters['audiobooks/getLibraryItemCoverSrc'](this.audiobook, this.placeholderUrl) | ||||
|     }, | ||||
|     hasCover() { | ||||
|       return !!this.audiobook.book.cover | ||||
|  | ||||
| @ -19,7 +19,7 @@ | ||||
|     </div> | ||||
| 
 | ||||
|     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> | ||||
|       <component v-if="audiobook && show" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> | ||||
|       <component v-if="libraryItem && show" :is="tabName" :library-item="libraryItem" :processing.sync="processing" @close="show = false" @selectTab="selectTab" /> | ||||
|     </div> | ||||
|   </modals-modal> | ||||
| </template> | ||||
| @ -29,7 +29,7 @@ export default { | ||||
|   data() { | ||||
|     return { | ||||
|       processing: false, | ||||
|       audiobook: null, | ||||
|       libraryItem: null, | ||||
|       fetchOnShow: false, | ||||
|       tabs: [ | ||||
|         { | ||||
| @ -42,11 +42,6 @@ export default { | ||||
|           title: 'Cover', | ||||
|           component: 'modals-edit-tabs-cover' | ||||
|         }, | ||||
|         // { | ||||
|         //   id: 'tracks', | ||||
|         //   title: 'Tracks', | ||||
|         //   component: 'modals-edit-tabs-tracks' | ||||
|         // }, | ||||
|         { | ||||
|           id: 'chapters', | ||||
|           title: 'Chapters', | ||||
| @ -89,12 +84,12 @@ export default { | ||||
|             this.selectedTab = availableTabIds[0] | ||||
|           } | ||||
| 
 | ||||
|           if (this.audiobook && this.audiobook.id === this.selectedAudiobookId) { | ||||
|           if (this.libraryItem && this.libraryItem.id === this.selectedLibraryItemId) { | ||||
|             if (this.fetchOnShow) this.fetchFull() | ||||
|             return | ||||
|           } | ||||
|           this.fetchOnShow = false | ||||
|           this.audiobook = null | ||||
|           this.libraryItem = null | ||||
|           this.init() | ||||
|           this.registerListeners() | ||||
|         } else { | ||||
| @ -148,26 +143,29 @@ export default { | ||||
|       return _tab ? _tab.component : '' | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.selectedAudiobook.isMissing | ||||
|       return this.selectedLibraryItem.isMissing | ||||
|     }, | ||||
|     selectedAudiobook() { | ||||
|       return this.$store.state.selectedAudiobook || {} | ||||
|     selectedLibraryItem() { | ||||
|       return this.$store.state.selectedLibraryItem || {} | ||||
|     }, | ||||
|     selectedAudiobookId() { | ||||
|       return this.selectedAudiobook.id | ||||
|     selectedLibraryItemId() { | ||||
|       return this.selectedLibraryItem.id | ||||
|     }, | ||||
|     book() { | ||||
|       return this.audiobook ? this.audiobook.book || {} : {} | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     title() { | ||||
|       return this.book.title || 'No Title' | ||||
|       return this.mediaMetadata.title || 'No Title' | ||||
|     }, | ||||
|     bookshelfBookIds() { | ||||
|       return this.$store.state.bookshelfBookIds || [] | ||||
|     }, | ||||
|     currentBookshelfIndex() { | ||||
|       if (!this.bookshelfBookIds.length) return 0 | ||||
|       return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedAudiobookId) | ||||
|       return this.bookshelfBookIds.findIndex((bid) => bid === this.selectedLibraryItemId) | ||||
|     }, | ||||
|     canGoPrev() { | ||||
|       return this.bookshelfBookIds.length && this.currentBookshelfIndex > 0 | ||||
| @ -188,7 +186,7 @@ export default { | ||||
|       }) | ||||
|       this.processing = false | ||||
|       if (prevBook) { | ||||
|         this.$store.commit('showEditModalOnTab', { audiobook: prevBook, tab: this.selectedTab }) | ||||
|         this.$store.commit('showEditModalOnTab', { libraryItem: prevBook, tab: this.selectedTab }) | ||||
|         this.$nextTick(this.init) | ||||
|       } else { | ||||
|         console.error('Book not found', prevBookId) | ||||
| @ -205,7 +203,7 @@ export default { | ||||
|       }) | ||||
|       this.processing = false | ||||
|       if (nextBook) { | ||||
|         this.$store.commit('showEditModalOnTab', { audiobook: nextBook, tab: this.selectedTab }) | ||||
|         this.$store.commit('showEditModalOnTab', { libraryItem: nextBook, tab: this.selectedTab }) | ||||
|         this.$nextTick(this.init) | ||||
|       } else { | ||||
|         console.error('Book not found', nextBookId) | ||||
| @ -223,16 +221,16 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedAudiobookId }) | ||||
|       this.$store.commit('audiobooks/addListener', { meth: this.audiobookUpdated, id: 'edit-modal', audiobookId: this.selectedLibraryItemId }) | ||||
|       this.fetchFull() | ||||
|     }, | ||||
|     async fetchFull() { | ||||
|       try { | ||||
|         this.processing = true | ||||
|         this.audiobook = await this.$axios.$get(`/api/books/${this.selectedAudiobookId}`) | ||||
|         this.libraryItem = await this.$axios.$get(`/api/items/${this.selectedLibraryItemId}?expanded=1`) | ||||
|         this.processing = false | ||||
|       } catch (error) { | ||||
|         console.error('Failed to fetch audiobook', this.selectedAudiobookId, error) | ||||
|         console.error('Failed to fetch audiobook', this.selectedLibraryItemId, error) | ||||
|         this.processing = false | ||||
|         this.show = false | ||||
|       } | ||||
|  | ||||
| @ -31,7 +31,7 @@ | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
| @ -42,7 +42,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
| @ -52,7 +52,7 @@ export default { | ||||
|   computed: {}, | ||||
|   methods: { | ||||
|     init() { | ||||
|       this.chapters = this.audiobook.chapters || [] | ||||
|       this.chapters = this.libraryItem.chapters || [] | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -2,9 +2,9 @@ | ||||
|   <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6 relative"> | ||||
|     <div class="flex"> | ||||
|       <div class="relative"> | ||||
|         <covers-book-cover :audiobook="audiobook" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
|         <covers-book-cover :library-item="libraryItem" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
|         <!-- book cover overlay --> | ||||
|         <div v-if="book.cover" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> | ||||
|         <div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100"> | ||||
|           <div class="absolute top-0 left-0 w-full h-16 bg-gradient-to-b from-black-600 to-transparent" /> | ||||
|           <div class="p-1 absolute top-1 right-1 text-red-500 rounded-full w-8 h-8 cursor-pointer hover:text-red-400 shadow-sm" @click="removeCover"> | ||||
|             <span class="material-icons">delete</span> | ||||
| @ -82,7 +82,7 @@ | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
| @ -102,7 +102,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) { | ||||
| @ -134,17 +134,17 @@ export default { | ||||
|     bookCoverAspectRatio() { | ||||
|       return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 | ||||
|     }, | ||||
|     audiobookId() { | ||||
|       return this.audiobook ? this.audiobook.id : null | ||||
|     libraryItemId() { | ||||
|       return this.libraryItem ? this.libraryItem.id : null | ||||
|     }, | ||||
|     book() { | ||||
|       return this.audiobook ? this.audiobook.book || {} : {} | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     audiobookPath() { | ||||
|       return this.audiobook ? this.audiobook.path : null | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     otherFiles() { | ||||
|       return this.audiobook ? this.audiobook.otherFiles || [] : [] | ||||
|     libraryFiles() { | ||||
|       return this.libraryItem ? this.libraryItem.libraryFiles || [] : [] | ||||
|     }, | ||||
|     userCanUpload() { | ||||
|       return this.$store.getters['user/getUserCanUpload'] | ||||
| @ -153,12 +153,11 @@ export default { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     localCovers() { | ||||
|       return this.otherFiles | ||||
|         .filter((f) => f.filetype === 'image') | ||||
|       return this.libraryFiles | ||||
|         .filter((f) => f.fileType === 'image') | ||||
|         .map((file) => { | ||||
|           var _file = { ...file } | ||||
|           var imgRelPath = _file.path.replace(this.audiobookPath, '') | ||||
|           _file.localPath = `/s/book/${this.audiobookId}/${imgRelPath}` | ||||
|           _file.localPath = `/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath)}` | ||||
|           return _file | ||||
|         }) | ||||
|     } | ||||
| @ -170,7 +169,7 @@ export default { | ||||
|       form.set('cover', this.selectedFile) | ||||
| 
 | ||||
|       this.$axios | ||||
|         .$post(`/api/books/${this.audiobook.id}/cover`, form) | ||||
|         .$post(`/api/books/${this.libraryItemId}/cover`, form) | ||||
|         .then((data) => { | ||||
|           if (data.error) { | ||||
|             this.$toast.error(data.error) | ||||
| @ -203,17 +202,17 @@ export default { | ||||
|     }, | ||||
|     init() { | ||||
|       this.showLocalCovers = false | ||||
|       if (this.coversFound.length && (this.searchTitle !== this.book.title || this.searchAuthor !== this.book.authorFL)) { | ||||
|       if (this.coversFound.length && (this.searchTitle !== this.mediaMetadata.title || this.searchAuthor !== this.mediaMetadata.authorName)) { | ||||
|         this.coversFound = [] | ||||
|         this.hasSearched = false | ||||
|       } | ||||
|       this.imageUrl = this.book.cover || '' | ||||
|       this.searchTitle = this.book.title || '' | ||||
|       this.searchAuthor = this.book.authorFL || '' | ||||
|       this.imageUrl = this.media.coverPath || '' | ||||
|       this.searchTitle = this.mediaMetadata.title || '' | ||||
|       this.searchAuthor = this.mediaMetadata.authorName || '' | ||||
|       this.provider = localStorage.getItem('book-provider') || 'openlibrary' | ||||
|     }, | ||||
|     removeCover() { | ||||
|       if (!this.book.cover) { | ||||
|       if (!this.media.coverPath) { | ||||
|         this.imageUrl = '' | ||||
|         return | ||||
|       } | ||||
| @ -223,7 +222,7 @@ export default { | ||||
|       this.updateCover(this.imageUrl) | ||||
|     }, | ||||
|     async updateCover(cover) { | ||||
|       if (cover === this.book.cover) { | ||||
|       if (cover === this.media.coverPath) { | ||||
|         console.warn('Cover has not changed..', cover) | ||||
|         return | ||||
|       } | ||||
| @ -233,7 +232,7 @@ export default { | ||||
| 
 | ||||
|       // Download cover from url and use | ||||
|       if (cover.startsWith('http:') || cover.startsWith('https:')) { | ||||
|         success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, { url: cover }).catch((error) => { | ||||
|         success = await this.$axios.$post(`/api/items/${this.libraryItemId}/cover`, { url: cover }).catch((error) => { | ||||
|           console.error('Failed to download cover from url', error) | ||||
|           if (error.response && error.response.data) { | ||||
|             this.$toast.error(error.response.data) | ||||
| @ -247,7 +246,7 @@ export default { | ||||
|             cover: cover | ||||
|           } | ||||
|         } | ||||
|         success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|         success = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           if (error.response && error.response.data) { | ||||
|             this.$toast.error(error.response.data) | ||||
| @ -259,7 +258,7 @@ export default { | ||||
|         this.$toast.success('Update Successful') | ||||
|         this.$emit('close') | ||||
|       } else { | ||||
|         this.imageUrl = this.book.cover || '' | ||||
|         this.imageUrl = this.media.coverPath || '' | ||||
|       } | ||||
|       this.isProcessing = false | ||||
|     }, | ||||
| @ -292,7 +291,7 @@ export default { | ||||
|     setCover(coverFile) { | ||||
|       this.isProcessing = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/books/${this.audiobook.id}/coverfile`, coverFile) | ||||
|         .$patch(`/api/books/${this.libraryItemId}/coverfile`, coverFile) | ||||
|         .then((data) => { | ||||
|           console.log('response data', data) | ||||
|           if (data && typeof data === 'string') { | ||||
|  | ||||
| @ -13,7 +13,8 @@ | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-3/4 px-1"> | ||||
|             <ui-text-input-with-label v-model="details.author" label="Author" /> | ||||
|             <!-- <ui-text-input-with-label v-model="details.authors" label="Author" /> --> | ||||
|             <p>Authors placeholder</p> | ||||
|           </div> | ||||
|           <div class="flex-grow px-1"> | ||||
|             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> | ||||
| @ -22,10 +23,11 @@ | ||||
| 
 | ||||
|         <div class="flex mt-2 -mx-1"> | ||||
|           <div class="w-3/4 px-1"> | ||||
|             <ui-input-dropdown ref="seriesDropdown" v-model="details.series" label="Series" :items="series" /> | ||||
|             <p>Series placeholder</p> | ||||
|             <!-- <ui-input-dropdown ref="seriesDropdown" 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 #" /> | ||||
|             <!-- <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> --> | ||||
|           </div> | ||||
|         </div> | ||||
| 
 | ||||
| @ -64,7 +66,7 @@ | ||||
| 
 | ||||
|       <div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'box-shadow-sm-up border-t border-primary border-opacity-50'"> | ||||
|         <div class="flex items-center px-4"> | ||||
|           <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> | ||||
|           <ui-btn v-if="userCanDelete" color="error" type="button" class="h-8" :padding-x="3" small @click.stop.prevent="removeItem">Remove</ui-btn> | ||||
| 
 | ||||
|           <div class="flex-grow" /> | ||||
| 
 | ||||
| @ -91,7 +93,7 @@ | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
| @ -105,7 +107,6 @@ export default { | ||||
|         author: null, | ||||
|         narrator: null, | ||||
|         series: null, | ||||
|         volumeNumber: null, | ||||
|         publishYear: null, | ||||
|         publisher: null, | ||||
|         language: null, | ||||
| @ -122,7 +123,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
| @ -142,13 +143,16 @@ export default { | ||||
|       return this.$store.getters['user/getIsRoot'] | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return !!this.audiobook && !!this.audiobook.isMissing | ||||
|       return !!this.libraryItem && !!this.libraryItem.isMissing | ||||
|     }, | ||||
|     audiobookId() { | ||||
|       return this.audiobook ? this.audiobook.id : null | ||||
|     libraryItemId() { | ||||
|       return this.libraryItem ? this.libraryItem.id : null | ||||
|     }, | ||||
|     book() { | ||||
|       return this.audiobook ? this.audiobook.book || {} : {} | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     userCanDelete() { | ||||
|       return this.$store.getters['user/getUserCanDelete'] | ||||
| @ -166,7 +170,7 @@ export default { | ||||
|       return this.$store.state.libraries.filterData || {} | ||||
|     }, | ||||
|     libraryId() { | ||||
|       return this.audiobook ? this.audiobook.libraryId : null | ||||
|       return this.libraryItem ? this.libraryItem.libraryId : null | ||||
|     }, | ||||
|     libraryProvider() { | ||||
|       return this.$store.getters['libraries/getLibraryProvider'](this.libraryId) || 'google' | ||||
| @ -185,13 +189,13 @@ export default { | ||||
|         author: this.details.author !== this.book.author ? this.details.author : null | ||||
|       } | ||||
|       this.$axios | ||||
|         .$post(`/api/books/${this.audiobookId}/match`, matchOptions) | ||||
|         .$post(`/api/books/${this.libraryItemId}/match`, matchOptions) | ||||
|         .then((res) => { | ||||
|           this.quickMatching = false | ||||
|           if (res.warning) { | ||||
|             this.$toast.warning(res.warning) | ||||
|           } else if (res.updated) { | ||||
|             this.$toast.success('Audiobook details updated') | ||||
|             this.$toast.success('Item details updated') | ||||
|           } else { | ||||
|             this.$toast.info('No updates were made') | ||||
|           } | ||||
| @ -236,7 +240,7 @@ export default { | ||||
|     saveMetadata() { | ||||
|       this.savingMetadata = true | ||||
|       this.$root.socket.once('save_metadata_complete', this.saveMetadataComplete) | ||||
|       this.$root.socket.emit('save_metadata', this.audiobookId) | ||||
|       this.$root.socket.emit('save_metadata', this.libraryItemId) | ||||
|     }, | ||||
|     submitForm() { | ||||
|       if (this.isProcessing) { | ||||
| @ -260,7 +264,7 @@ export default { | ||||
|         tags: this.newTags | ||||
|       } | ||||
| 
 | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, updatePayload).catch((error) => { | ||||
|       var updatedAudiobook = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, updatePayload).catch((error) => { | ||||
|         console.error('Failed to update', error) | ||||
|         return false | ||||
|       }) | ||||
| @ -271,35 +275,34 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     init() { | ||||
|       this.details.title = this.book.title | ||||
|       this.details.subtitle = this.book.subtitle | ||||
|       this.details.description = this.book.description | ||||
|       this.details.author = this.book.author | ||||
|       this.details.narrator = this.book.narrator | ||||
|       this.details.genres = this.book.genres || [] | ||||
|       this.details.series = this.book.series | ||||
|       this.details.volumeNumber = this.book.volumeNumber | ||||
|       this.details.publishYear = this.book.publishYear | ||||
|       this.details.publisher = this.book.publisher || null | ||||
|       this.details.language = this.book.language || null | ||||
|       this.details.isbn = this.book.isbn || null | ||||
|       this.details.asin = this.book.asin || null | ||||
|       this.details.title = this.mediaMetadata.title | ||||
|       this.details.subtitle = this.mediaMetadata.subtitle | ||||
|       this.details.description = this.mediaMetadata.description | ||||
|       this.details.authors = this.mediaMetadata.authors | ||||
|       this.details.narrator = this.mediaMetadata.narrator | ||||
|       this.details.genres = this.mediaMetadata.genres || [] | ||||
|       this.details.series = this.mediaMetadata.series | ||||
|       this.details.publishYear = this.mediaMetadata.publishYear | ||||
|       this.details.publisher = this.mediaMetadata.publisher || null | ||||
|       this.details.language = this.mediaMetadata.language || null | ||||
|       this.details.isbn = this.mediaMetadata.isbn || null | ||||
|       this.details.asin = this.mediaMetadata.asin || null | ||||
| 
 | ||||
|       this.newTags = this.audiobook.tags || [] | ||||
|       this.newTags = this.media.tags || [] | ||||
|     }, | ||||
|     deleteAudiobook() { | ||||
|       if (confirm(`Are you sure you want to remove this audiobook?\n\n*Does not delete your files, only removes the audiobook from AudioBookshelf`)) { | ||||
|     removeItem() { | ||||
|       if (confirm(`Are you sure you want to remove this item?\n\n*Does not delete your files, only removes the item from audiobookshelf`)) { | ||||
|         this.isProcessing = true | ||||
|         this.$axios | ||||
|           .$delete(`/api/books/${this.audiobookId}`) | ||||
|           .$delete(`/api/books/${this.libraryItemId}`) | ||||
|           .then(() => { | ||||
|             console.log('Audiobook removed') | ||||
|             this.$toast.success('Audiobook Removed') | ||||
|             console.log('Item removed') | ||||
|             this.$toast.success('Item Removed') | ||||
|             this.$emit('close') | ||||
|             this.isProcessing = false | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error('Remove Audiobook failed', error) | ||||
|             console.error('Remove item failed', error) | ||||
|             this.isProcessing = false | ||||
|           }) | ||||
|       } | ||||
|  | ||||
| @ -65,7 +65,7 @@ | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
| @ -86,14 +86,14 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookId() { | ||||
|       return this.audiobook ? this.audiobook.id : null | ||||
|     libraryItemId() { | ||||
|       return this.libraryItem ? this.libraryItem.id : null | ||||
|     }, | ||||
|     _audiobook() { | ||||
|       return this.audiobook || {} | ||||
|     media() { | ||||
|       return this.libraryItem ? this.libraryItem.media || {} : {} | ||||
|     }, | ||||
|     downloads() { | ||||
|       return this.$store.getters['downloads/getDownloads'](this.audiobookId) | ||||
|       return this.$store.getters['downloads/getDownloads'](this.libraryItemId) | ||||
|     }, | ||||
|     singleAudioDownload() { | ||||
|       return this.downloads.find((d) => d.type === 'singleAudio') | ||||
| @ -108,24 +108,21 @@ export default { | ||||
|       return this.zipDownload ? this.zipDownload.status : false | ||||
|     }, | ||||
|     isSingleTrack() { | ||||
|       if (!this.audiobook.tracks) return false | ||||
|       return this.audiobook.tracks.length === 1 | ||||
|       if (!this.libraryItem.tracks) return false | ||||
|       return this.libraryItem.tracks.length === 1 | ||||
|     }, | ||||
|     singleTrackPath() { | ||||
|       if (!this.isSingleTrack) return null | ||||
|       return this.audiobook.tracks[0].path | ||||
|     }, | ||||
|     audioFiles() { | ||||
|       return this.audiobook ? this.audiobook.audioFiles || [] : [] | ||||
|     }, | ||||
|     otherFiles() { | ||||
|       return this.audiobook ? this.audiobook.otherFiles || [] : [] | ||||
|     libraryFiles() { | ||||
|       return this.libraryItem.libraryFiles | ||||
|     }, | ||||
|     totalFiles() { | ||||
|       return this.audioFiles.length + this.otherFiles.length | ||||
|       return this.libraryFiles.length | ||||
|     }, | ||||
|     showM4bDownload() { | ||||
|       return !this._audiobook.isMissing && !this._audiobook.isInvalid && this._audiobook.tracks.length | ||||
|       return !this.libraryItem.isMissing && this.media.tracks.length | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|  | ||||
| @ -9,7 +9,7 @@ | ||||
|           </div> | ||||
|           <div class="flex-grow" /> | ||||
|           <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||
|           <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`"> | ||||
|           <nuxt-link v-if="userCanUpdate" :to="`/item/${libraryItem.id}/edit`"> | ||||
|             <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||
|           </nuxt-link> | ||||
|         </div> | ||||
| @ -21,20 +21,20 @@ | ||||
|             <th class="text-left">Duration</th> | ||||
|             <th v-if="showDownload" class="text-center">Download</th> | ||||
|           </tr> | ||||
|           <template v-for="track in tracksCleaned"> | ||||
|           <template v-for="track in tracks"> | ||||
|             <tr :key="track.index"> | ||||
|               <td class="text-center"> | ||||
|                 <p>{{ track.index }}</p> | ||||
|               </td> | ||||
|               <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td> | ||||
|               <td class="font-sans">{{ showFullPath ? track.path : track.filename }}</td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $bytesPretty(track.size) }} | ||||
|                 {{ $bytesPretty(track.metadata.size) }} | ||||
|               </td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $secondsToTimestamp(track.duration) }} | ||||
|               </td> | ||||
|               <td v-if="showDownload" class="font-mono text-center"> | ||||
|                 <a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|                 <a :href="`/s/item/${libraryItem.id}/${track.metadata.relPath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </template> | ||||
| @ -43,26 +43,26 @@ | ||||
|       <div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div> | ||||
|     </div> | ||||
| 
 | ||||
|     <tables-all-files-table :audiobook="audiobook" /> | ||||
|     <tables-library-files-table :files="libraryFiles" :library-item-id="libraryItem.id" :is-missing="isMissing" /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       tracks: null, | ||||
|       tracks: [], | ||||
|       showFullPath: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
| @ -70,22 +70,11 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookPath() { | ||||
|       return this.audiobook.path | ||||
|     media() { | ||||
|       return this.libraryItem.media || {} | ||||
|     }, | ||||
|     tracksCleaned() { | ||||
|       return this.tracks.map((track) => { | ||||
|         var trackPath = track.path.replace(/\\/g, '/') | ||||
|         var audiobookPath = this.audiobookPath.replace(/\\/g, '/') | ||||
| 
 | ||||
|         return { | ||||
|           ...track, | ||||
|           relativePath: trackPath | ||||
|             .replace(audiobookPath + '/', '') | ||||
|             .replace(/%/g, '%25') | ||||
|             .replace(/#/g, '%23') | ||||
|         } | ||||
|       }) | ||||
|     libraryFiles() { | ||||
|       return this.libraryItem.libraryFiles || [] | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
| @ -97,18 +86,18 @@ export default { | ||||
|       return this.$store.getters['user/getUserCanDownload'] | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|       return this.libraryItem.isMissing | ||||
|     }, | ||||
|     showDownload() { | ||||
|       return this.userCanDownload && !this.isMissing | ||||
|     }, | ||||
|     hasTracks() { | ||||
|       return this.audiobook.tracks.length | ||||
|       return this.tracks.length | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     init() { | ||||
|       this.tracks = this.audiobook.tracks | ||||
|       this.tracks = this.media.tracks || [] | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| @ -94,14 +94,14 @@ | ||||
| export default { | ||||
|   props: { | ||||
|     processing: Boolean, | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       audiobookId: null, | ||||
|       libraryItemId: null, | ||||
|       searchTitle: null, | ||||
|       searchAuthor: null, | ||||
|       lastSearch: null, | ||||
| @ -126,7 +126,7 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|     libraryItem: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
| @ -209,19 +209,19 @@ export default { | ||||
|         isbn: true | ||||
|       } | ||||
| 
 | ||||
|       if (this.audiobook.id !== this.audiobookId) { | ||||
|       if (this.libraryItem.id !== this.libraryItemId) { | ||||
|         this.searchResults = [] | ||||
|         this.hasSearched = false | ||||
|         this.audiobookId = this.audiobook.id | ||||
|         this.libraryItemId = this.libraryItem.id | ||||
|       } | ||||
| 
 | ||||
|       if (!this.audiobook.book || !this.audiobook.book.title) { | ||||
|       if (!this.libraryItem.media || !this.libraryItem.media.metadata.title) { | ||||
|         this.searchTitle = null | ||||
|         this.searchAuthor = null | ||||
|         return | ||||
|       } | ||||
|       this.searchTitle = this.audiobook.book.title | ||||
|       this.searchAuthor = this.audiobook.book.authorFL || '' | ||||
|       this.searchTitle = this.libraryItem.media.metadata.title | ||||
|       this.searchAuthor = this.libraryItem.media.metadata.authorName || '' | ||||
|       this.provider = localStorage.getItem('book-provider') || 'google' | ||||
|     }, | ||||
|     selectMatch(match) { | ||||
| @ -247,7 +247,7 @@ export default { | ||||
|         var coverPayload = { | ||||
|           url: updatePayload.cover | ||||
|         } | ||||
|         var success = await this.$axios.$post(`/api/books/${this.audiobook.id}/cover`, coverPayload).catch((error) => { | ||||
|         var success = await this.$axios.$post(`/api/books/${this.libraryItemId}/cover`, coverPayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
| @ -264,7 +264,7 @@ export default { | ||||
|         var bookUpdatePayload = { | ||||
|           book: updatePayload | ||||
|         } | ||||
|         var success = await this.$axios.$patch(`/api/books/${this.audiobook.id}`, bookUpdatePayload).catch((error) => { | ||||
|         var success = await this.$axios.$patch(`/api/books/${this.libraryItemId}`, bookUpdatePayload).catch((error) => { | ||||
|           console.error('Failed to update', error) | ||||
|           return false | ||||
|         }) | ||||
|  | ||||
| @ -1,110 +0,0 @@ | ||||
| <template> | ||||
|   <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> | ||||
|     <template v-if="hasTracks"> | ||||
|       <div class="w-full bg-primary px-4 py-2 flex items-center"> | ||||
|         <div class="h-7 w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> | ||||
|           <span class="text-sm font-mono">{{ tracks.length }}</span> | ||||
|         </div> | ||||
|         <div class="flex-grow" /> | ||||
|         <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||
|         <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobook.id}/edit`" class="mr-4"> | ||||
|           <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||
|         </nuxt-link> | ||||
|       </div> | ||||
|       <table class="text-sm tracksTable"> | ||||
|         <tr class="font-book"> | ||||
|           <th>#</th> | ||||
|           <th class="text-left">Filename</th> | ||||
|           <th class="text-left">Size</th> | ||||
|           <th class="text-left">Duration</th> | ||||
|           <th v-if="showDownload" class="text-center">Download</th> | ||||
|         </tr> | ||||
|         <template v-for="track in tracksCleaned"> | ||||
|           <tr :key="track.index"> | ||||
|             <td class="text-center"> | ||||
|               <p>{{ track.index }}</p> | ||||
|             </td> | ||||
|             <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td> | ||||
|             <td class="font-mono"> | ||||
|               {{ $bytesPretty(track.size) }} | ||||
|             </td> | ||||
|             <td class="font-mono"> | ||||
|               {{ $secondsToTimestamp(track.duration) }} | ||||
|             </td> | ||||
|             <td v-if="showDownload" class="font-mono text-center"> | ||||
|               <a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|             </td> | ||||
|           </tr> | ||||
|         </template> | ||||
|       </table> | ||||
|     </template> | ||||
|     <div v-else class="flex my-4 text-center justify-center text-xl">No Audio Tracks</div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     audiobook: { | ||||
|       type: Object, | ||||
|       default: () => {} | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       tracks: null, | ||||
|       showFullPath: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     audiobook: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         if (newVal) this.init() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookPath() { | ||||
|       return this.audiobook.path | ||||
|     }, | ||||
|     tracksCleaned() { | ||||
|       return this.tracks.map((track) => { | ||||
|         var trackPath = track.path.replace(/\\/g, '/') | ||||
|         var audiobookPath = this.audiobookPath.replace(/\\/g, '/') | ||||
| 
 | ||||
|         return { | ||||
|           ...track, | ||||
|           relativePath: trackPath | ||||
|             .replace(audiobookPath + '/', '') | ||||
|             .replace(/%/g, '%25') | ||||
|             .replace(/#/g, '%23') | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     userCanUpdate() { | ||||
|       return this.$store.getters['user/getUserCanUpdate'] | ||||
|     }, | ||||
|     userCanDownload() { | ||||
|       return this.$store.getters['user/getUserCanDownload'] | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|     }, | ||||
|     showDownload() { | ||||
|       return this.userCanDownload && !this.isMissing | ||||
|     }, | ||||
|     hasTracks() { | ||||
|       return this.audiobook.tracks.length | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     init() { | ||||
|       this.tracks = this.audiobook.tracks | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -1,14 +1,11 @@ | ||||
| <template> | ||||
|   <div class="w-full my-2"> | ||||
|     <div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar"> | ||||
|       <p class="pr-2 md:pr-4">Other Files</p> | ||||
|       <p class="pr-2 md:pr-4">Library Files</p> | ||||
|       <div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center"> | ||||
|         <span class="text-sm font-mono">{{ files.length }}</span> | ||||
|       </div> | ||||
|       <div class="flex-grow" /> | ||||
|       <!-- <nuxt-link :to="`/audiobook/${audiobookId}/edit`" class="mr-4"> | ||||
|         <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||
|       </nuxt-link> --> | ||||
|       <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||
|       <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''"> | ||||
|         <span class="material-icons text-4xl">expand_more</span> | ||||
| @ -22,19 +19,19 @@ | ||||
|             <th class="text-left px-4 w-24">Filetype</th> | ||||
|             <th v-if="userCanDownload && !isMissing" class="text-center w-20">Download</th> | ||||
|           </tr> | ||||
|           <template v-for="file in otherFilesCleaned"> | ||||
|           <template v-for="file in files"> | ||||
|             <tr :key="file.path"> | ||||
|               <td class="font-book pl-2"> | ||||
|                 {{ showFullPath ? file.fullPath : file.path }} | ||||
|                 {{ showFullPath ? file.metadata.path : file.metadata.relPath }} | ||||
|               </td> | ||||
|               <td class="text-xs"> | ||||
|                 <div class="flex items-center"> | ||||
|                   <span v-if="file.filetype === 'ebook'" class="material-icons text-base mr-1 cursor-pointer text-white text-opacity-60 hover:text-opacity-100" @click="readEbookClick(file)">auto_stories </span> | ||||
|                   <p>{{ file.filetype }}</p> | ||||
|                   <p>{{ file.metadata.ext }}</p> | ||||
|                 </div> | ||||
|               </td> | ||||
|               <td v-if="userCanDownload && !isMissing" class="text-center"> | ||||
|                 <a :href="`/s/book/${audiobookId}/${file.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|                 <a :href="`/s/item/${libraryItemId}${$encodeUriPath(file.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </template> | ||||
| @ -51,10 +48,8 @@ export default { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     }, | ||||
|     audiobook: { | ||||
|       type: Object, | ||||
|       default: () => null | ||||
|     } | ||||
|     libraryItemId: String, | ||||
|     isMissing: Boolean | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
| @ -63,40 +58,19 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookId() { | ||||
|       return this.audiobook.id | ||||
|     }, | ||||
|     audiobookPath() { | ||||
|       return this.audiobook.path | ||||
|     }, | ||||
|     otherFilesCleaned() { | ||||
|       return this.files.map((file) => { | ||||
|         return { | ||||
|           ...file, | ||||
|           relativePath: this.getRelativePath(file.path) | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.audiobook.isMissing | ||||
|     }, | ||||
|     userCanDownload() { | ||||
|       return this.$store.getters['user/getUserCanDownload'] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     readEbookClick(file) { | ||||
|       this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file }) | ||||
|       // this.$store.commit('showEReaderForFile', { audiobook: this.audiobook, file }) | ||||
|     }, | ||||
|     clickBar() { | ||||
|       this.showFiles = !this.showFiles | ||||
|     }, | ||||
|     getRelativePath(path) { | ||||
|       var relativePath = path.replace(/\\/g, '/').replace(this.audiobookPath.replace(/\\/g, '/') + '/', '') | ||||
|       return this.$encodeUriPath(relativePath) | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
| @ -8,7 +8,7 @@ | ||||
|       <!-- <span class="bg-black-400 rounded-xl py-1 px-2 text-sm font-mono">{{ tracks.length }}</span> --> | ||||
|       <div class="flex-grow" /> | ||||
|       <ui-btn small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">Full Path</ui-btn> | ||||
|       <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${audiobookId}/edit`" class="mr-2 md:mr-4"> | ||||
|       <nuxt-link v-if="userCanUpdate" :to="`/audiobook/${libraryItemId}/edit`" class="mr-2 md:mr-4"> | ||||
|         <ui-btn small color="primary">Manage Tracks</ui-btn> | ||||
|       </nuxt-link> | ||||
|       <div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showTracks ? 'transform rotate-180' : ''"> | ||||
| @ -25,20 +25,20 @@ | ||||
|             <th class="text-left w-20">Duration</th> | ||||
|             <th v-if="userCanDownload" class="text-center w-20">Download</th> | ||||
|           </tr> | ||||
|           <template v-for="track in tracksCleaned"> | ||||
|           <template v-for="track in tracks"> | ||||
|             <tr :key="track.index"> | ||||
|               <td class="text-center"> | ||||
|                 <p>{{ track.index }}</p> | ||||
|               </td> | ||||
|               <td class="font-sans">{{ showFullPath ? track.fullPath : track.filename }}</td> | ||||
|               <td class="font-sans">{{ showFullPath ? track.metadata.path : track.metadata.filename }}</td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $bytesPretty(track.size) }} | ||||
|                 {{ $bytesPretty(track.metadata.size) }} | ||||
|               </td> | ||||
|               <td class="font-mono"> | ||||
|                 {{ $secondsToTimestamp(track.duration) }} | ||||
|               </td> | ||||
|               <td v-if="userCanDownload" class="text-center"> | ||||
|                 <a :href="`/s/book/${audiobook.id}/${track.relativePath}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|                 <a :href="`/s/item/${libraryItemId}${$encodeUriPath(track.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a> | ||||
|               </td> | ||||
|             </tr> | ||||
|           </template> | ||||
| @ -55,10 +55,7 @@ export default { | ||||
|       type: Array, | ||||
|       default: () => [] | ||||
|     }, | ||||
|     audiobook: { | ||||
|       type: Object, | ||||
|       default: () => null | ||||
|     } | ||||
|     libraryItemId: String | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
| @ -67,20 +64,6 @@ export default { | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     audiobookId() { | ||||
|       return this.audiobook.id | ||||
|     }, | ||||
|     audiobookPath() { | ||||
|       return this.audiobook.path | ||||
|     }, | ||||
|     tracksCleaned() { | ||||
|       return this.tracks.map((track) => { | ||||
|         return { | ||||
|           ...track, | ||||
|           relativePath: this.getRelativePath(track.path) | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     userToken() { | ||||
|       return this.$store.getters['user/getToken'] | ||||
|     }, | ||||
| @ -94,10 +77,6 @@ export default { | ||||
|   methods: { | ||||
|     clickBar() { | ||||
|       this.showTracks = !this.showTracks | ||||
|     }, | ||||
|     getRelativePath(path) { | ||||
|       var relativePath = path.replace(/\\/g, '/').replace(this.audiobookPath.replace(/\\/g, '/') + '/', '') | ||||
|       return this.$encodeUriPath(relativePath) | ||||
|     } | ||||
|   }, | ||||
|   mounted() {} | ||||
|  | ||||
| @ -74,7 +74,6 @@ module.exports = { | ||||
| 
 | ||||
|   proxy: { | ||||
|     '/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }, | ||||
|     '/local/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }, | ||||
|     '/lib/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }, | ||||
|     '/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }, | ||||
|     '/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' }, | ||||
|  | ||||
| @ -164,7 +164,7 @@ | ||||
| 
 | ||||
|           <tables-audio-files-table v-if="otherAudioFiles.length" :audiobook-id="audiobook.id" :files="otherAudioFiles" class="mt-6" /> | ||||
| 
 | ||||
|           <tables-other-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" /> | ||||
|           <tables-library-files-table v-if="otherFiles.length" :audiobook="audiobook" :files="otherFiles" class="mt-6" /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|  | ||||
							
								
								
									
										475
									
								
								client/pages/item/_id/index.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										475
									
								
								client/pages/item/_id/index.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,475 @@ | ||||
| <template> | ||||
|   <div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamAudiobook ? 'streaming' : ''"> | ||||
|     <div class="w-full h-full overflow-y-auto px-2 py-6 md:p-8"> | ||||
|       <div class="flex flex-col md:flex-row max-w-6xl mx-auto"> | ||||
|         <div class="w-full flex justify-center md:block md:w-52" style="min-width: 208px"> | ||||
|           <div class="relative" style="height: fit-content"> | ||||
|             <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
| 
 | ||||
|             <!-- Book Progress Bar --> | ||||
|             <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> | ||||
| 
 | ||||
|             <!-- Book Cover Overlay --> | ||||
|             <div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent> | ||||
|               <div v-show="showPlayButton && !streaming" class="h-full flex items-center justify-center pointer-events-none"> | ||||
|                 <div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="startStream"> | ||||
|                   <span class="material-icons text-4xl">play_circle_filled</span> | ||||
|                 </div> | ||||
|               </div> | ||||
| 
 | ||||
|               <span class="absolute bottom-2.5 right-2.5 z-10 material-icons text-lg cursor-pointer text-white text-opacity-75 hover:text-opacity-100 hover:scale-110 transform duration-200" @click="showEditCover">edit</span> | ||||
|             </div> | ||||
|           </div> | ||||
|         </div> | ||||
|         <div class="flex-grow px-2 py-6 md:py-0 md:px-10"> | ||||
|           <div class="flex justify-center"> | ||||
|             <div class="mb-4"> | ||||
|               <div class="flex sm:items-end flex-col sm:flex-row"> | ||||
|                 <h1 class="text-2xl md:text-3xl font-sans"> | ||||
|                   {{ title }} | ||||
|                 </h1> | ||||
|                 <p v-if="subtitle" class="sm:ml-4 text-gray-400 text-xl md:text-2xl">{{ subtitle }}</p> | ||||
|               </div> | ||||
|               <!-- <p v-if="subtitle" class="ml-4 text-gray-400 text-2xl block sm:hidden">{{ subtitle }}</p> --> | ||||
| 
 | ||||
|               <p v-if="authorsList.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl"> | ||||
|                 by <nuxt-link v-for="(author, index) in authorsList" :key="index" :to="`/library/${libraryId}/bookshelf?filter=authors.${$encode(author)}`" class="hover:underline">{{ author }}<span v-if="index < authorsList.length - 1">, </span></nuxt-link> | ||||
|               </p> | ||||
|               <p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p> | ||||
| 
 | ||||
|               <nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${libraryId}/series/${_series.id}}`" class="hover:underline font-sans text-gray-300 text-lg leading-7"> {{ _series.text }}</nuxt-link> | ||||
| 
 | ||||
|               <div v-if="narrator" class="flex py-0.5 mt-4"> | ||||
|                 <div class="w-32"> | ||||
|                   <span class="text-white text-opacity-60 uppercase text-sm">Narrated By</span> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <template v-for="(narrator, index) in narrators"> | ||||
|                     <nuxt-link :key="narrator" :to="`/library/${libraryId}/bookshelf?filter=narrators.${$encode(narrator)}`" class="hover:underline">{{ narrator }}</nuxt-link | ||||
|                     ><span :key="index" v-if="index < narrators.length - 1">, </span> | ||||
|                   </template> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div v-if="publishYear" class="flex py-0.5"> | ||||
|                 <div class="w-32"> | ||||
|                   <span class="text-white text-opacity-60 uppercase text-sm">Publish Year</span> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   {{ publishYear }} | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div class="flex py-0.5" v-if="genres.length"> | ||||
|                 <div class="w-32"> | ||||
|                   <span class="text-white text-opacity-60 uppercase text-sm">Genres</span> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   <template v-for="(genre, index) in genres"> | ||||
|                     <nuxt-link :key="genre" :to="`/library/${libraryId}/bookshelf?filter=genres.${$encode(genre)}`" class="hover:underline">{{ genre }}</nuxt-link | ||||
|                     ><span :key="index" v-if="index < genres.length - 1">, </span> | ||||
|                   </template> | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div v-if="tracks.length" class="flex py-0.5"> | ||||
|                 <div class="w-32"> | ||||
|                   <span class="text-white text-opacity-60 uppercase text-sm">Duration</span> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   {{ durationPretty }} | ||||
|                 </div> | ||||
|               </div> | ||||
|               <div v-if="tracks.length" class="flex py-0.5"> | ||||
|                 <div class="w-32"> | ||||
|                   <span class="text-white text-opacity-60 uppercase text-sm">Size</span> | ||||
|                 </div> | ||||
|                 <div> | ||||
|                   {{ sizePretty }} | ||||
|                 </div> | ||||
|               </div> | ||||
|             </div> | ||||
|             <div class="hidden md:block flex-grow" /> | ||||
|           </div> | ||||
| 
 | ||||
|           <!-- Alerts --> | ||||
|           <div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center"> | ||||
|             <span class="material-icons text-2xl">warning_amber</span> | ||||
|             <p class="ml-4">Book has no audio tracks but has valid ebook files. The e-reader is experimental and can be turned on in config.</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <!-- Progress --> | ||||
|           <div v-if="progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''"> | ||||
|             <p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> | ||||
|             <p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p> | ||||
|             <p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> | ||||
|             <p class="text-gray-400 text-xs pt-1">Started {{ $formatDate(userProgressStartedAt, 'MM/dd/yyyy') }}</p> | ||||
| 
 | ||||
|             <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> | ||||
|               <span class="material-icons text-sm">close</span> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <div class="flex items-center justify-center md:justify-start pt-4"> | ||||
|             <ui-btn v-if="showPlayButton" :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream"> | ||||
|               <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span> | ||||
|               {{ streaming ? 'Streaming' : 'Play' }} | ||||
|             </ui-btn> | ||||
|             <ui-btn v-else-if="isMissing || isInvalid" color="error" :padding-x="4" small class="flex items-center h-9 mr-2"> | ||||
|               <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">error</span> | ||||
|               {{ isMissing ? 'Missing' : 'Incomplete' }} | ||||
|             </ui-btn> | ||||
| 
 | ||||
|             <ui-btn v-if="showExperimentalFeatures && numEbooks" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> | ||||
|               <span class="material-icons -ml-2 pr-2 text-white">auto_stories</span> | ||||
|               Read | ||||
|             </ui-btn> | ||||
| 
 | ||||
|             <ui-tooltip v-if="userCanUpdate" text="Edit" direction="top"> | ||||
|               <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip v-if="userCanDownload" :disabled="isMissing" text="Download" direction="top"> | ||||
|               <ui-icon-btn icon="download" :disabled="isMissing" class="mx-0.5" @click="downloadClick" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top"> | ||||
|               <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" /> | ||||
|             </ui-tooltip> | ||||
| 
 | ||||
|             <ui-tooltip text="Collections" direction="top"> | ||||
|               <ui-icon-btn icon="collections_bookmark" class="mx-0.5" outlined @click="collectionsClick" /> | ||||
|             </ui-tooltip> | ||||
|           </div> | ||||
| 
 | ||||
|           <div class="my-4 max-w-2xl"> | ||||
|             <p class="text-base text-gray-100 whitespace-pre-line">{{ description }}</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4"> | ||||
|             <p class="text-sm mb-2"> | ||||
|               Missing Parts <span class="text-sm">({{ missingParts.length }})</span> | ||||
|             </p> | ||||
|             <p class="text-sm font-mono">{{ missingPartChunks.join(', ') }}</p> | ||||
|           </div> | ||||
| 
 | ||||
|           <div v-if="invalidParts.length" class="bg-error border-red-800 shadow-md p-4"> | ||||
|             <p class="text-sm mb-2"> | ||||
|               Invalid Parts <span class="text-sm">({{ invalidParts.length }})</span> | ||||
|             </p> | ||||
|             <div> | ||||
|               <p v-for="part in invalidParts" :key="part.filename" class="text-sm font-mono">{{ part.filename }}: {{ part.error }}</p> | ||||
|             </div> | ||||
|           </div> | ||||
| 
 | ||||
|           <tables-tracks-table v-if="tracks.length" :tracks="tracks" :library-item-id="libraryItemId" class="mt-6" /> | ||||
| 
 | ||||
|           <!-- <tables-audio-files-table v-if="otherAudioFiles.length" :library-item-id="libraryItemId" :files="otherAudioFiles" class="mt-6" /> --> | ||||
| 
 | ||||
|           <tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" /> | ||||
|         </div> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   async asyncData({ store, params, app, redirect, route }) { | ||||
|     if (!store.state.user.user) { | ||||
|       return redirect(`/login?redirect=${route.path}`) | ||||
|     } | ||||
|     var item = await app.$axios.$get(`/api/items/${params.id}?expanded=1`).catch((error) => { | ||||
|       console.error('Failed', error) | ||||
|       return false | ||||
|     }) | ||||
|     if (!item) { | ||||
|       console.error('No item...', params.id) | ||||
|       return redirect('/') | ||||
|     } | ||||
|     return { | ||||
|       libraryItem: item | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       isRead: false, | ||||
|       resettingProgress: false, | ||||
|       isProcessingReadUpdate: false | ||||
|     } | ||||
|   }, | ||||
|   watch: { | ||||
|     userIsRead: { | ||||
|       immediate: true, | ||||
|       handler(newVal) { | ||||
|         this.isRead = newVal | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     coverAspectRatio() { | ||||
|       return this.$store.getters['getServerSetting']('coverAspectRatio') | ||||
|     }, | ||||
|     bookCoverAspectRatio() { | ||||
|       return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE ? 1 : 1.6 | ||||
|     }, | ||||
|     bookCoverWidth() { | ||||
|       return 208 | ||||
|     }, | ||||
|     isDeveloperMode() { | ||||
|       return this.$store.state.developerMode | ||||
|     }, | ||||
|     showExperimentalFeatures() { | ||||
|       return this.$store.state.showExperimentalFeatures | ||||
|     }, | ||||
|     missingPartChunks() { | ||||
|       if (this.missingParts === 1) return this.missingParts[0] | ||||
|       var chunks = [] | ||||
| 
 | ||||
|       var currentIndex = this.missingParts[0] | ||||
|       var currentChunk = [this.missingParts[0]] | ||||
| 
 | ||||
|       for (let i = 1; i < this.missingParts.length; i++) { | ||||
|         var partIndex = this.missingParts[i] | ||||
|         if (currentIndex === partIndex - 1) { | ||||
|           currentChunk.push(partIndex) | ||||
|           currentIndex = partIndex | ||||
|         } else { | ||||
|           // console.log('Chunk ended', currentChunk.join(', '), currentIndex, partIndex) | ||||
|           if (currentChunk.length === 0) { | ||||
|             console.error('How is current chunk 0?', currentChunk.join(', ')) | ||||
|           } | ||||
|           chunks.push(currentChunk) | ||||
|           currentChunk = [partIndex] | ||||
|           currentIndex = partIndex | ||||
|         } | ||||
|       } | ||||
|       if (currentChunk.length) { | ||||
|         chunks.push(currentChunk) | ||||
|       } | ||||
|       chunks = chunks.map((chunk) => { | ||||
|         if (chunk.length === 1) return chunk[0] | ||||
|         else return `${chunk[0]}-${chunk[chunk.length - 1]}` | ||||
|       }) | ||||
|       return chunks | ||||
|     }, | ||||
|     isMissing() { | ||||
|       return this.libraryItem.isMissing | ||||
|     }, | ||||
|     isInvalid() { | ||||
|       return this.libraryItem.isInvalid | ||||
|     }, | ||||
|     showPlayButton() { | ||||
|       return !this.isMissing && !this.isInvalid && this.tracks.length | ||||
|     }, | ||||
|     missingParts() { | ||||
|       return this.libraryItem.missingParts || [] | ||||
|     }, | ||||
|     invalidParts() { | ||||
|       return this.libraryItem.invalidParts || [] | ||||
|     }, | ||||
|     libraryId() { | ||||
|       return this.libraryItem.libraryId | ||||
|     }, | ||||
|     folderId() { | ||||
|       return this.libraryItem.folderId | ||||
|     }, | ||||
|     libraryItemId() { | ||||
|       return this.libraryItem.id | ||||
|     }, | ||||
|     media() { | ||||
|       return this.libraryItem.media || {} | ||||
|     }, | ||||
|     mediaMetadata() { | ||||
|       return this.media.metadata || {} | ||||
|     }, | ||||
|     title() { | ||||
|       return this.mediaMetadata.title || 'No Title' | ||||
|     }, | ||||
|     publishYear() { | ||||
|       return this.mediaMetadata.publishYear | ||||
|     }, | ||||
|     narrator() { | ||||
|       return this.mediaMetadata.narratorName | ||||
|     }, | ||||
|     subtitle() { | ||||
|       return this.mediaMetadata.subtitle | ||||
|     }, | ||||
|     genres() { | ||||
|       return this.mediaMetadata.genres || [] | ||||
|     }, | ||||
|     authors() { | ||||
|       return this.mediaMetadata.authors || [] | ||||
|     }, | ||||
|     authorsList() { | ||||
|       return this.authors.map((au) => au.name) | ||||
|     }, | ||||
|     narrators() { | ||||
|       return this.mediaMetadata.narrators || [] | ||||
|     }, | ||||
|     series() { | ||||
|       return this.media.series || [] | ||||
|     }, | ||||
|     seriesList() { | ||||
|       return this.series.map((se) => { | ||||
|         var text = se.name | ||||
|         if (se.sequence) text += ` #${se.sequence}` | ||||
|         return { | ||||
|           ...se, | ||||
|           text | ||||
|         } | ||||
|       }) | ||||
|     }, | ||||
|     durationPretty() { | ||||
|       return this.$elapsedPretty(this.media.duration) | ||||
|     }, | ||||
|     duration() { | ||||
|       return this.media.duration | ||||
|     }, | ||||
|     sizePretty() { | ||||
|       return this.$bytesPretty(this.media.size) | ||||
|     }, | ||||
|     libraryFiles() { | ||||
|       return this.libraryItem.libraryFiles || [] | ||||
|     }, | ||||
|     otherAudioFiles() { | ||||
|       return this.audioFiles.filter((af) => { | ||||
|         return !this.tracks.find((t) => t.path === af.path) | ||||
|       }) | ||||
|     }, | ||||
|     tracks() { | ||||
|       return this.media.tracks || [] | ||||
|     }, | ||||
|     audioFiles() { | ||||
|       return this.media.audioFiles || [] | ||||
|     }, | ||||
|     ebooks() { | ||||
|       return this.media.ebookFiles | ||||
|     }, | ||||
|     showExperimentalReadAlert() { | ||||
|       return !this.tracks.length && this.ebooks.length && !this.showExperimentalFeatures | ||||
|     }, | ||||
|     numEbooks() { | ||||
|       return this.media.numEbooks | ||||
|     }, | ||||
|     description() { | ||||
|       return this.mediaMetadata.description || '' | ||||
|     }, | ||||
|     userAudiobooks() { | ||||
|       return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} | ||||
|     }, | ||||
|     userAudiobook() { | ||||
|       return this.userAudiobooks[this.libraryItemId] || null | ||||
|     }, | ||||
|     userCurrentTime() { | ||||
|       return this.userAudiobook ? this.userAudiobook.currentTime : 0 | ||||
|     }, | ||||
|     userIsRead() { | ||||
|       return this.userAudiobook ? !!this.userAudiobook.isRead : false | ||||
|     }, | ||||
|     userTimeRemaining() { | ||||
|       return this.duration - this.userCurrentTime | ||||
|     }, | ||||
|     progressPercent() { | ||||
|       return this.userAudiobook ? Math.max(Math.min(1, this.userAudiobook.progress), 0) : 0 | ||||
|     }, | ||||
|     userProgressStartedAt() { | ||||
|       return this.userAudiobook ? this.userAudiobook.startedAt : 0 | ||||
|     }, | ||||
|     userProgressFinishedAt() { | ||||
|       return this.userAudiobook ? this.userAudiobook.finishedAt : 0 | ||||
|     }, | ||||
|     streamAudiobook() { | ||||
|       return this.$store.state.streamAudiobook | ||||
|     }, | ||||
|     streaming() { | ||||
|       return this.streamAudiobook && this.streamAudiobook.id === this.libraryItemId | ||||
|     }, | ||||
|     userCanUpdate() { | ||||
|       return this.$store.getters['user/getUserCanUpdate'] | ||||
|     }, | ||||
|     userCanDelete() { | ||||
|       return this.$store.getters['user/getUserCanDelete'] | ||||
|     }, | ||||
|     userCanDownload() { | ||||
|       return this.$store.getters['user/getUserCanDownload'] | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     showEditCover() { | ||||
|       this.$store.commit('setBookshelfBookIds', []) | ||||
|       this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' }) | ||||
|     }, | ||||
|     openEbook() { | ||||
|       this.$store.commit('showEReader', this.libraryItem) | ||||
|     }, | ||||
|     toggleRead() { | ||||
|       var updatePayload = { | ||||
|         isRead: !this.isRead | ||||
|       } | ||||
|       this.isProcessingReadUpdate = true | ||||
|       this.$axios | ||||
|         .$patch(`/api/me/audiobook/${this.libraryItemId}`, updatePayload) | ||||
|         .then(() => { | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|           this.isProcessingReadUpdate = false | ||||
|           this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||
|         }) | ||||
|     }, | ||||
|     startStream() { | ||||
|       this.$eventBus.$emit('play-audiobook', this.libraryItem.id) | ||||
|     }, | ||||
|     editClick() { | ||||
|       this.$store.commit('setBookshelfBookIds', []) | ||||
|       this.$store.commit('showEditModal', this.libraryItem) | ||||
|     }, | ||||
|     audiobookUpdated() { | ||||
|       console.log('Audiobook Updated - Fetch full audiobook') | ||||
|       this.$axios | ||||
|         .$get(`/api/books/${this.libraryItemId}`) | ||||
|         .then((audiobook) => { | ||||
|           console.log('Updated audiobook', audiobook) | ||||
|           this.libraryItem = audiobook | ||||
|         }) | ||||
|         .catch((error) => { | ||||
|           console.error('Failed', error) | ||||
|         }) | ||||
|     }, | ||||
|     clearProgressClick() { | ||||
|       if (confirm(`Are you sure you want to reset your progress?`)) { | ||||
|         this.resettingProgress = true | ||||
|         this.$axios | ||||
|           .$patch(`/api/me/audiobook/${this.libraryItemId}/reset-progress`) | ||||
|           .then(() => { | ||||
|             console.log('Progress reset complete') | ||||
|             this.$toast.success(`Your progress was reset`) | ||||
|             this.resettingProgress = false | ||||
|           }) | ||||
|           .catch((error) => { | ||||
|             console.error('Progress reset failed', error) | ||||
|             this.resettingProgress = false | ||||
|           }) | ||||
|       } | ||||
|     }, | ||||
|     downloadClick() { | ||||
|       this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' }) | ||||
|     }, | ||||
|     collectionsClick() { | ||||
|       this.$store.commit('setSelectedAudiobook', this.libraryItem) | ||||
|       this.$store.commit('globals/setShowUserCollectionsModal', true) | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.libraryItemId, meth: this.libraryItemUpdated }) | ||||
| 
 | ||||
|     // use this audiobooks library id as the current | ||||
|     if (this.libraryId) { | ||||
|       this.$store.commit('libraries/setCurrentLibrary', this.libraryId) | ||||
|     } | ||||
|   }, | ||||
|   beforeDestroy() { | ||||
|     this.$store.commit('audiobooks/removeListener', 'audiobook') | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -76,7 +76,7 @@ export default class CastPlayer extends EventEmitter { | ||||
| 
 | ||||
|     this.currentTime = startTime | ||||
| 
 | ||||
|     var coverImg = this.ctx.$store.getters['audiobooks/getBookCoverSrc'](audiobook) | ||||
|     var coverImg = this.ctx.$store.getters['audiobooks/getLibraryItemCoverSrc'](audiobook) | ||||
|     if (process.env.NODE_ENV === 'development') { | ||||
|       this.coverUrl = coverImg | ||||
|     } else { | ||||
|  | ||||
| @ -3,7 +3,7 @@ export default (ctx) => { | ||||
|     // Fetch background covers for chromecast (temp)
 | ||||
|     var covers = await ctx.$axios.$get(`/api/libraries/${ctx.$store.state.libraries.currentLibraryId}/books/all?limit=40&minified=1`).then((data) => { | ||||
|       return data.results.filter((b) => b.book.cover).map((ab) => { | ||||
|         var coverUrl = ctx.$store.getters['audiobooks/getBookCoverSrc'](ab) | ||||
|         var coverUrl = ctx.$store.getters['audiobooks/getLibraryItemCoverSrc'](ab) | ||||
|         if (process.env.NODE_ENV === 'development') return coverUrl | ||||
|         return `${window.location.origin}${coverUrl}` | ||||
|       }) | ||||
|  | ||||
| @ -12,25 +12,21 @@ export const state = () => ({ | ||||
| }) | ||||
| 
 | ||||
| export const getters = { | ||||
|   getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => { | ||||
|     if (!bookItem) return placeholder | ||||
|     var book = bookItem.book | ||||
|     if (!book || !book.cover || book.cover === placeholder) return placeholder | ||||
|   getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = '/book_placeholder.jpg') => { | ||||
|     if (!libraryItem) return placeholder | ||||
|     var media = libraryItem.media | ||||
|     if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder | ||||
| 
 | ||||
|     // Absolute URL covers (should no longer be used)
 | ||||
|     if (book.cover.startsWith('http:') || book.cover.startsWith('https:')) return book.cover | ||||
|     if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath | ||||
| 
 | ||||
|     var userToken = rootGetters['user/getToken'] | ||||
|     var bookLastUpdate = book.lastUpdate || Date.now() | ||||
| 
 | ||||
|     if (!bookItem.id) { | ||||
|       console.error('No book item id', bookItem) | ||||
|     } | ||||
|     var lastUpdate = libraryItem.updatedAt || Date.now() | ||||
| 
 | ||||
|     if (process.env.NODE_ENV !== 'production') { // Testing
 | ||||
|       return `http://localhost:3333/api/books/${bookItem.id}/cover?token=${userToken}&ts=${bookLastUpdate}` | ||||
|       return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
|     } | ||||
|     return `/api/books/${bookItem.id}/cover?token=${userToken}&ts=${bookLastUpdate}` | ||||
|     return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}` | ||||
|   } | ||||
| } | ||||
| 
 | ||||
|  | ||||
| @ -9,6 +9,7 @@ export const state = () => ({ | ||||
|   showEditModal: false, | ||||
|   showEReader: false, | ||||
|   selectedAudiobook: null, | ||||
|   selectedLibraryItem: null, | ||||
|   selectedAudiobookFile: null, | ||||
|   developerMode: false, | ||||
|   selectedAudiobooks: [], | ||||
| @ -108,14 +109,14 @@ export const mutations = { | ||||
|   setStreamAudiobook(state, audiobook) { | ||||
|     state.streamAudiobook = audiobook | ||||
|   }, | ||||
|   showEditModal(state, audiobook) { | ||||
|   showEditModal(state, libraryItem) { | ||||
|     state.editModalTab = 'details' | ||||
|     state.selectedAudiobook = audiobook | ||||
|     state.selectedLibraryItem = libraryItem | ||||
|     state.showEditModal = true | ||||
|   }, | ||||
|   showEditModalOnTab(state, { audiobook, tab }) { | ||||
|   showEditModalOnTab(state, { libraryItem, tab }) { | ||||
|     state.editModalTab = tab | ||||
|     state.selectedAudiobook = audiobook | ||||
|     state.selectedLibraryItem = libraryItem | ||||
|     state.showEditModal = true | ||||
|   }, | ||||
|   setEditModalTab(state, tab) { | ||||
| @ -124,15 +125,15 @@ export const mutations = { | ||||
|   setShowEditModal(state, val) { | ||||
|     state.showEditModal = val | ||||
|   }, | ||||
|   showEReader(state, audiobook) { | ||||
|   showEReader(state, libraryItem) { | ||||
|     state.selectedAudiobookFile = null | ||||
|     state.selectedAudiobook = audiobook | ||||
|     state.selectedLibraryItem = libraryItem | ||||
| 
 | ||||
|     state.showEReader = true | ||||
|   }, | ||||
|   showEReaderForFile(state, { audiobook, file }) { | ||||
|   showEReaderForFile(state, { libraryItem, file }) { | ||||
|     state.selectedAudiobookFile = file | ||||
|     state.selectedAudiobook = audiobook | ||||
|     state.selectedLibraryItem = libraryItem | ||||
| 
 | ||||
|     state.showEReader = true | ||||
|   }, | ||||
| @ -142,8 +143,8 @@ export const mutations = { | ||||
|   setDeveloperMode(state, val) { | ||||
|     state.developerMode = val | ||||
|   }, | ||||
|   setSelectedAudiobook(state, val) { | ||||
|     Vue.set(state, 'selectedAudiobook', val) | ||||
|   setSelectedLibraryItem(state, val) { | ||||
|     Vue.set(state, 'selectedLibraryItem', val) | ||||
|   }, | ||||
|   setSelectedAudiobooks(state, audiobooks) { | ||||
|     Vue.set(state, 'selectedAudiobooks', audiobooks) | ||||
|  | ||||
| @ -124,7 +124,7 @@ export const actions = { | ||||
|     } | ||||
| 
 | ||||
|     this.$axios | ||||
|       .$get(`/api/libraries/${state.currentLibraryId}/filters`) | ||||
|       .$get(`/api/libraries/${state.currentLibraryId}/filterdata`) | ||||
|       .then((data) => { | ||||
|         commit('setLibraryFilterData', data) | ||||
|       }) | ||||
|  | ||||
| @ -19,8 +19,9 @@ new LibraryItem({ | ||||
|   lastScan: 1646784672127, | ||||
|   scanVersion: 1.72, | ||||
|   isMissing: false, | ||||
|   entityType: 'book', | ||||
|   entity: { // Book.js
 | ||||
|   mediaType: 'book', | ||||
|   media: { // Book.js
 | ||||
|     coverPath: '/metadata/books/li_abai123wir/cover.webp', | ||||
|     metadata: { // BookMetadata.js
 | ||||
|       title: 'Wizards First Rule', | ||||
|       subtitle: null, | ||||
|  | ||||
| @ -14,6 +14,7 @@ const UserController = require('./controllers/UserController') | ||||
| const CollectionController = require('./controllers/CollectionController') | ||||
| const MeController = require('./controllers/MeController') | ||||
| const BackupController = require('./controllers/BackupController') | ||||
| const LibraryItemController = require('./controllers/LibraryItemController') | ||||
| 
 | ||||
| const BookFinder = require('./finders/BookFinder') | ||||
| const AuthorFinder = require('./finders/AuthorFinder') | ||||
| @ -53,19 +54,29 @@ class ApiController { | ||||
|     this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) | ||||
|     this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) | ||||
| 
 | ||||
|     this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary2.bind(this)) | ||||
|     this.router.get('/libraries/:id/books', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) | ||||
|     this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/series/:series', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this)) | ||||
|     this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this)) | ||||
|     this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalized.bind(this)) | ||||
|     this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this)) | ||||
|     this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) | ||||
|     this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) | ||||
|     this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this)) | ||||
|     this.router.post('/libraries/:id/matchbooks', LibraryController.middleware.bind(this), LibraryController.matchBooks.bind(this)) | ||||
|     this.router.post('/libraries/order', LibraryController.reorder.bind(this)) | ||||
| 
 | ||||
|     // Legacy
 | ||||
|     this.router.get('/libraries/:id/books/all', LibraryController.middleware.bind(this), LibraryController.getBooksForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/categories', LibraryController.middleware.bind(this), LibraryController.getLibraryCategories.bind(this)) | ||||
|     this.router.get('/libraries/:id/filters', LibraryController.middleware.bind(this), LibraryController.getLibraryFilters.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Item Routes
 | ||||
|     //
 | ||||
|     this.router.get('/items/:id', LibraryItemController.middleware.bind(this), LibraryItemController.findOne.bind(this)) | ||||
|     this.router.get('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.getCover.bind(this)) | ||||
| 
 | ||||
|     //
 | ||||
|     // Book Routes
 | ||||
|     //
 | ||||
|  | ||||
| @ -10,14 +10,14 @@ class CacheManager { | ||||
|     this.CoverCachePath = Path.join(this.CachePath, 'covers') | ||||
|   } | ||||
| 
 | ||||
|   async handleCoverCache(res, audiobook, options = {}) { | ||||
|   async handleCoverCache(res, libraryItem, options = {}) { | ||||
|     const format = options.format || 'webp' | ||||
|     const width = options.width || 400 | ||||
|     const height = options.height || null | ||||
| 
 | ||||
|     res.type(`image/${format}`) | ||||
| 
 | ||||
|     var path = Path.join(this.CoverCachePath, `${audiobook.id}_${width}${height ? `x${height}` : ''}`) + '.' + format | ||||
|     var path = Path.join(this.CoverCachePath, `${libraryItem.id}_${width}${height ? `x${height}` : ''}`) + '.' + format | ||||
| 
 | ||||
|     // Cache exists
 | ||||
|     if (await fs.pathExists(path)) { | ||||
| @ -35,7 +35,7 @@ class CacheManager { | ||||
|     // Write cache
 | ||||
|     await fs.ensureDir(this.CoverCachePath) | ||||
| 
 | ||||
|     let writtenFile = await resizeImage(audiobook.book.coverFullPath, path, width, height) | ||||
|     let writtenFile = await resizeImage(libraryItem.media.coverPath, path, width, height) | ||||
|     if (!writtenFile) return res.sendStatus(400) | ||||
| 
 | ||||
|     var readStream = fs.createReadStream(writtenFile) | ||||
|  | ||||
| @ -69,7 +69,6 @@ class Server { | ||||
| 
 | ||||
|     Logger.logManager = this.logManager | ||||
| 
 | ||||
|     this.expressApp = null | ||||
|     this.server = null | ||||
|     this.io = null | ||||
| 
 | ||||
| @ -154,8 +153,6 @@ class Server { | ||||
|     await this.init() | ||||
| 
 | ||||
|     const app = express() | ||||
|     this.expressApp = app | ||||
| 
 | ||||
|     this.server = http.createServer(app) | ||||
| 
 | ||||
|     app.use(this.auth.cors) | ||||
| @ -167,9 +164,6 @@ class Server { | ||||
|     const distPath = Path.join(global.appRoot, '/client/dist') | ||||
|     app.use(express.static(distPath)) | ||||
| 
 | ||||
|     // Old static path for covers
 | ||||
|     app.use('/local', this.authMiddleware.bind(this), express.static(global.AudiobookPath)) | ||||
| 
 | ||||
|     // Metadata folder static path
 | ||||
|     app.use('/metadata', this.authMiddleware.bind(this), express.static(global.MetadataPath)) | ||||
| 
 | ||||
| @ -179,6 +173,9 @@ class Server { | ||||
|     // Static folder
 | ||||
|     app.use(express.static(Path.join(global.appRoot, 'static'))) | ||||
| 
 | ||||
|     app.use('/api', this.authMiddleware.bind(this), this.apiController.router) | ||||
|     app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router) | ||||
| 
 | ||||
|     // Static file routes
 | ||||
|     app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => { | ||||
|       var library = this.db.libraries.find(lib => lib.id === req.params.library) | ||||
| @ -192,6 +189,7 @@ class Server { | ||||
|     }) | ||||
| 
 | ||||
|     // Book static file routes
 | ||||
|     // LEGACY
 | ||||
|     app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => { | ||||
|       var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id) | ||||
|       if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id) | ||||
| @ -201,6 +199,16 @@ class Server { | ||||
|       res.sendFile(fullPath) | ||||
|     }) | ||||
| 
 | ||||
|     // Library Item static file routes
 | ||||
|     app.get('/s/item/:id/*', this.authMiddleware.bind(this), (req, res) => { | ||||
|       var item = this.db.libraryItems.find(ab => ab.id === req.params.id) | ||||
|       if (!item) return res.status(404).send('Item not found with id ' + req.params.id) | ||||
| 
 | ||||
|       var remainingPath = req.params['0'] | ||||
|       var fullPath = Path.join(item.path, remainingPath) | ||||
|       res.sendFile(fullPath) | ||||
|     }) | ||||
| 
 | ||||
|     // EBook static file routes
 | ||||
|     app.get('/ebook/:library/:folder/*', (req, res) => { | ||||
|       var library = this.db.libraries.find(lib => lib.id === req.params.library) | ||||
| @ -214,8 +222,10 @@ class Server { | ||||
|     }) | ||||
| 
 | ||||
|     // Client dynamic routes
 | ||||
|     app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/audiobook/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
 | ||||
|     app.get('/audiobook/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) // LEGACY
 | ||||
|     app.get('/item/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/item/:id/edit', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/library/:library', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/library/:library/search', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/library/:library/bookshelf/:id?', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
| @ -224,31 +234,16 @@ class Server { | ||||
|     app.get('/config/users/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
|     app.get('/collection/:id', (req, res) => res.sendFile(Path.join(distPath, 'index.html'))) | ||||
| 
 | ||||
|     app.use('/api', this.authMiddleware.bind(this), this.apiController.router) | ||||
|     app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router) | ||||
| 
 | ||||
|     // Incomplete work in progress
 | ||||
|     // app.use('/feeds', this.rssFeeds.router)
 | ||||
| 
 | ||||
|     app.post('/login', this.getLoginRateLimiter(), (req, res) => this.auth.login(req, res)) | ||||
|     app.post('/upload', this.authMiddleware.bind(this), this.handleUpload.bind(this)) | ||||
| 
 | ||||
|     var loginRateLimiter = this.getLoginRateLimiter() | ||||
|     app.post('/login', loginRateLimiter, (req, res) => this.auth.login(req, res)) | ||||
| 
 | ||||
|     app.post('/logout', this.authMiddleware.bind(this), this.logout.bind(this)) | ||||
| 
 | ||||
|     app.get('/ping', (req, res) => { | ||||
|       Logger.info('Recieved ping') | ||||
|       res.json({ success: true }) | ||||
|     }) | ||||
| 
 | ||||
|     // Used in development to set-up streams without authentication
 | ||||
|     if (process.env.NODE_ENV !== 'production') { | ||||
|       app.use('/test-hls', this.hlsController.router) | ||||
|     } | ||||
| 
 | ||||
|     this.server.listen(this.Port, this.Host, () => { | ||||
|       Logger.info(`Running on http://${this.Host}:${this.Port}`) | ||||
|       Logger.info(`Listening on http://${this.Host}:${this.Port}`) | ||||
|     }) | ||||
| 
 | ||||
|     this.io = new SocketIO.Server(this.server, { | ||||
| @ -265,21 +260,25 @@ class Server { | ||||
|       } | ||||
|       socket.sheepClient = this.clients[socket.id] | ||||
| 
 | ||||
|       Logger.info('[SOCKET] Socket Connected', socket.id) | ||||
|       Logger.info('[Server] Socket Connected', socket.id) | ||||
| 
 | ||||
|       socket.on('auth', (token) => this.authenticateSocket(socket, token)) | ||||
| 
 | ||||
|       // TODO: Most of these web socket listeners will be moved to API routes instead
 | ||||
|       //         with the goal of the web socket connection being a nice-to-have not need-to-have
 | ||||
| 
 | ||||
|       // Scanning
 | ||||
|       socket.on('scan', this.scan.bind(this)) | ||||
|       socket.on('cancel_scan', this.cancelScan.bind(this)) | ||||
|       socket.on('scan_audiobook', (audiobookId) => this.scanAudiobook(socket, audiobookId)) | ||||
|       socket.on('save_metadata', (audiobookId) => this.saveMetadata(socket, audiobookId)) | ||||
| 
 | ||||
|       // Streaming
 | ||||
|       // Streaming (only still used in the mobile app)
 | ||||
|       socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId)) | ||||
|       socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket)) | ||||
|       socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData)) | ||||
| 
 | ||||
|       // Used to sync when playing local book on mobile, will be moved to API route
 | ||||
|       socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload)) | ||||
| 
 | ||||
|       // Downloading
 | ||||
| @ -299,10 +298,6 @@ class Server { | ||||
|       socket.on('update_bookmark', (payload) => this.updateBookmark(socket, payload)) | ||||
|       socket.on('delete_bookmark', (payload) => this.deleteBookmark(socket, payload)) | ||||
| 
 | ||||
|       socket.on('test', () => { | ||||
|         socket.emit('test_received', socket.id) | ||||
|       }) | ||||
| 
 | ||||
|       socket.on('disconnect', () => { | ||||
|         Logger.removeSocketListener(socket.id) | ||||
| 
 | ||||
|  | ||||
| @ -39,10 +39,9 @@ class LibraryController { | ||||
| 
 | ||||
|   async findOne(req, res) { | ||||
|     if (req.query.include && req.query.include === 'filterdata') { | ||||
|       var books = this.db.audiobooks.filter(ab => ab.libraryId === req.library.id) | ||||
|       return res.json({ | ||||
|         filterdata: libraryHelpers.getDistinctFilterData(books), | ||||
|         issues: libraryHelpers.getNumIssues(books), | ||||
|         filterdata: libraryHelpers.getDistinctFilterDataNew(req.libraryItems), | ||||
|         issues: libraryHelpers.getNumIssues(req.libraryItems), | ||||
|         library: req.library | ||||
|       }) | ||||
|     } | ||||
| @ -91,38 +90,70 @@ class LibraryController { | ||||
|     return res.json(libraryJson) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books
 | ||||
|   getBooksForLibrary(req, res) { | ||||
|   // api/libraries/:id/items
 | ||||
|   // TODO: Optimize this method, audiobooks are iterated through several times but can be combined
 | ||||
|   getLibraryItems(req, res) { | ||||
|     var libraryId = req.library.id | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
| 
 | ||||
|     if (req.query.filter) { | ||||
|       audiobooks = libraryHelpers.getFiltered(audiobooks, req.query.filter, req.user) | ||||
|     var media = req.query.media || 'all' | ||||
|     var libraryItems = this.db.libraryItems.filter(li => { | ||||
|       if (li.libraryId !== libraryId) return false | ||||
|       if (media != 'all') return li.mediaType == media | ||||
|       return true | ||||
|     }) | ||||
|     var payload = { | ||||
|       results: [], | ||||
|       total: libraryItems.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, | ||||
|       media, | ||||
|       minified: req.query.minified === '1', | ||||
|       collapseseries: req.query.collapseseries === '1' | ||||
|     } | ||||
| 
 | ||||
|     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 (payload.filterBy) { | ||||
|       libraryItems = libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user) | ||||
|       payload.total = libraryItems.length | ||||
|     } | ||||
| 
 | ||||
|     if (payload.sortBy) { | ||||
|       var sortKey = payload.sortBy | ||||
| 
 | ||||
|       // old sort key
 | ||||
|       if (sortKey.startsWith('book.')) { | ||||
|         sortKey = sortKey.replace('book.', 'media.metadata.') | ||||
|       } | ||||
| 
 | ||||
|       // Handle server setting sortingIgnorePrefix
 | ||||
|       if (sortKey === 'media.metadata.title' && this.db.serverSettings.sortingIgnorePrefix) { | ||||
|         // BookMetadata.js has titleIgnorePrefix getter
 | ||||
|         sortKey += 'IgnorePrefix' | ||||
|       } | ||||
| 
 | ||||
|       var direction = payload.sortDesc ? 'desc' : 'asc' | ||||
|       libraryItems = naturalSort(libraryItems)[direction]((li) => { | ||||
| 
 | ||||
|         // Supports dot notation strings i.e. "media.metadata.title"
 | ||||
|         return sortKey.split('.').reduce((a, b) => a[b], li) | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     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) | ||||
|     // TODO: Potentially implement collapse series again
 | ||||
|     libraryItems = libraryItems.map(ab => payload.minified ? ab.toJSONMinified() : ab.toJSON()) | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       var startIndex = payload.page * payload.limit | ||||
|       libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit) | ||||
|     } | ||||
|     res.json(audiobooks) | ||||
|     payload.results = libraryItems | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/all
 | ||||
|   // TODO: Optimize this method, audiobooks are iterated through several times but can be combined
 | ||||
|   getBooksForLibrary2(req, res) { | ||||
|   getBooksForLibrary(req, res) { | ||||
|     var libraryId = req.library.id | ||||
| 
 | ||||
|     var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId) | ||||
| @ -274,6 +305,7 @@ class LibraryController { | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // LEGACY
 | ||||
|   // api/libraries/:id/books/filters
 | ||||
|   async getLibraryFilters(req, res) { | ||||
|     var library = req.library | ||||
| @ -281,6 +313,45 @@ class LibraryController { | ||||
|     res.json(libraryHelpers.getDistinctFilterData(books)) | ||||
|   } | ||||
| 
 | ||||
|   async getLibraryFilterData(req, res) { | ||||
|     res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems)) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/books/personalized
 | ||||
|   async getLibraryUserPersonalized(req, res) { | ||||
|     var libraryItems = req.libraryItems | ||||
|     var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12 | ||||
|     var minified = req.query.minified === '1' | ||||
| 
 | ||||
|     var booksWithUserAb = libraryHelpers.getBooksWithUserAudiobook(req.user, libraryItems) | ||||
| 
 | ||||
|     var categories = [ | ||||
|       { | ||||
|         id: 'continue-reading', | ||||
|         label: 'Continue Reading', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyRead(booksWithUserAb, limitPerShelf, minified) | ||||
|       }, | ||||
|       { | ||||
|         id: 'recently-added', | ||||
|         label: 'Recently Added', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyAdded(libraryItems, limitPerShelf, minified) | ||||
|       }, | ||||
|       { | ||||
|         id: 'read-again', | ||||
|         label: 'Read Again', | ||||
|         type: 'books', | ||||
|         entities: libraryHelpers.getBooksMostRecentlyFinished(booksWithUserAb, limitPerShelf, minified) | ||||
|       } | ||||
|     ].filter(cats => { // Remove categories with no items
 | ||||
|       return cats.entities.length | ||||
|     }) | ||||
| 
 | ||||
|     res.json(categories) | ||||
|   } | ||||
| 
 | ||||
|   // LEGACY
 | ||||
|   // api/libraries/:id/books/categories
 | ||||
|   async getLibraryCategories(req, res) { | ||||
|     var library = req.library | ||||
| @ -491,6 +562,7 @@ class LibraryController { | ||||
|       return res.status(404).send('Library not found') | ||||
|     } | ||||
|     req.library = library | ||||
|     req.libraryItems = this.db.libraryItems.filter(li => li.libraryId === library.id) | ||||
|     next() | ||||
|   } | ||||
| } | ||||
|  | ||||
							
								
								
									
										37
									
								
								server/controllers/LibraryItemController.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								server/controllers/LibraryItemController.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,37 @@ | ||||
| const Logger = require('../Logger') | ||||
| const { reqSupportsWebp } = require('../utils/index') | ||||
| 
 | ||||
| class LibraryItemController { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   findOne(req, res) { | ||||
|     if (req.query.expanded == 1) return res.json(req.libraryItem.toJSONExpanded()) | ||||
|     res.json(req.libraryItem) | ||||
|   } | ||||
| 
 | ||||
|   // GET api/items/:id/cover
 | ||||
|   async getCover(req, res) { | ||||
|     let { query: { width, height, format }, libraryItem } = req | ||||
| 
 | ||||
|     const options = { | ||||
|       format: format || (reqSupportsWebp(req) ? 'webp' : 'jpeg'), | ||||
|       height: height ? parseInt(height) : null, | ||||
|       width: width ? parseInt(width) : null | ||||
|     } | ||||
|     return this.cacheManager.handleCoverCache(res, libraryItem, options) | ||||
|   } | ||||
| 
 | ||||
|   middleware(req, res, next) { | ||||
|     var item = this.db.libraryItems.find(li => li.id === req.params.id) | ||||
|     if (!item || !item.media || !item.media.coverPath) return res.sendStatus(404) | ||||
| 
 | ||||
|     // Check user can access this audiobooks library
 | ||||
|     if (!req.user.checkCanAccessLibrary(item.libraryId)) { | ||||
|       return res.sendStatus(403) | ||||
|     } | ||||
| 
 | ||||
|     req.libraryItem = item | ||||
|     next() | ||||
|   } | ||||
| } | ||||
| module.exports = new LibraryItemController() | ||||
| @ -1,7 +1,7 @@ | ||||
| const fs = require('fs-extra') | ||||
| const Logger = require('../Logger') | ||||
| const Path = require('path') | ||||
| const Author = require('../objects/Author') | ||||
| const Author = require('../objects/legacy/Author') | ||||
| const Audnexus = require('../providers/Audnexus') | ||||
| 
 | ||||
| const { downloadFile } = require('../utils/fileUtils') | ||||
|  | ||||
| @ -1,129 +0,0 @@ | ||||
| class AudioFileMetadata { | ||||
|   constructor(metadata) { | ||||
|     this.tagAlbum = null | ||||
|     this.tagArtist = null | ||||
|     this.tagGenre = null | ||||
|     this.tagTitle = null | ||||
|     this.tagSeries = null | ||||
|     this.tagSeriesPart = null | ||||
|     this.tagTrack = null | ||||
|     this.tagDisc = null | ||||
|     this.tagSubtitle = null | ||||
|     this.tagAlbumArtist = null | ||||
|     this.tagDate = null | ||||
|     this.tagComposer = null | ||||
|     this.tagPublisher = null | ||||
|     this.tagComment = null | ||||
|     this.tagDescription = null | ||||
|     this.tagEncoder = null | ||||
|     this.tagEncodedBy = null | ||||
|     this.tagIsbn = null | ||||
|     this.tagLanguage = null | ||||
|     this.tagASIN = null | ||||
| 
 | ||||
|     if (metadata) { | ||||
|       this.construct(metadata) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSON() { | ||||
|     // Only return the tags that are actually set
 | ||||
|     var json = {} | ||||
|     for (const key in this) { | ||||
|       if (key.startsWith('tag') && this[key]) { | ||||
|         json[key] = this[key] | ||||
|       } | ||||
|     } | ||||
|     return json | ||||
|   } | ||||
| 
 | ||||
|   construct(metadata) { | ||||
|     this.tagAlbum = metadata.tagAlbum || null | ||||
|     this.tagArtist = metadata.tagArtist || null | ||||
|     this.tagGenre = metadata.tagGenre || null | ||||
|     this.tagTitle = metadata.tagTitle || null | ||||
|     this.tagSeries = metadata.tagSeries || null | ||||
|     this.tagSeriesPart = metadata.tagSeriesPart || null | ||||
|     this.tagTrack = metadata.tagTrack || null | ||||
|     this.tagDisc = metadata.tagDisc || null | ||||
|     this.tagSubtitle = metadata.tagSubtitle || null | ||||
|     this.tagAlbumArtist = metadata.tagAlbumArtist || null | ||||
|     this.tagDate = metadata.tagDate || null | ||||
|     this.tagComposer = metadata.tagComposer || null | ||||
|     this.tagPublisher = metadata.tagPublisher || null | ||||
|     this.tagComment = metadata.tagComment || null | ||||
|     this.tagDescription = metadata.tagDescription || null | ||||
|     this.tagEncoder = metadata.tagEncoder || null | ||||
|     this.tagEncodedBy = metadata.tagEncodedBy || null | ||||
|     this.tagIsbn = metadata.tagIsbn || null | ||||
|     this.tagLanguage = metadata.tagLanguage || null | ||||
|     this.tagASIN = metadata.tagASIN || null | ||||
|   } | ||||
| 
 | ||||
|   // Data parsed in prober.js
 | ||||
|   setData(payload) { | ||||
|     this.tagAlbum = payload.file_tag_album || null | ||||
|     this.tagArtist = payload.file_tag_artist || null | ||||
|     this.tagGenre = payload.file_tag_genre || null | ||||
|     this.tagTitle = payload.file_tag_title || null | ||||
|     this.tagSeries = payload.file_tag_series || null | ||||
|     this.tagSeriesPart = payload.file_tag_seriespart || null | ||||
|     this.tagTrack = payload.file_tag_track || null | ||||
|     this.tagDisc = payload.file_tag_disc || null | ||||
|     this.tagSubtitle = payload.file_tag_subtitle || null | ||||
|     this.tagAlbumArtist = payload.file_tag_albumartist || null | ||||
|     this.tagDate = payload.file_tag_date || null | ||||
|     this.tagComposer = payload.file_tag_composer || null | ||||
|     this.tagPublisher = payload.file_tag_publisher || null | ||||
|     this.tagComment = payload.file_tag_comment || null | ||||
|     this.tagDescription = payload.file_tag_description || null | ||||
|     this.tagEncoder = payload.file_tag_encoder || null | ||||
|     this.tagEncodedBy = payload.file_tag_encodedby || null | ||||
|     this.tagIsbn = payload.file_tag_isbn || null | ||||
|     this.tagLanguage = payload.file_tag_language || null | ||||
|     this.tagASIN = payload.file_tag_asin || null | ||||
|   } | ||||
| 
 | ||||
|   updateData(payload) { | ||||
|     const dataMap = { | ||||
|       tagAlbum: payload.file_tag_album || null, | ||||
|       tagArtist: payload.file_tag_artist || null, | ||||
|       tagGenre: payload.file_tag_genre || null, | ||||
|       tagTitle: payload.file_tag_title || null, | ||||
|       tagSeries: payload.file_tag_series || null, | ||||
|       tagSeriesPart: payload.file_tag_seriespart || null, | ||||
|       tagTrack: payload.file_tag_track || null, | ||||
|       tagDisc: payload.file_tag_disc || null, | ||||
|       tagSubtitle: payload.file_tag_subtitle || null, | ||||
|       tagAlbumArtist: payload.file_tag_albumartist || null, | ||||
|       tagDate: payload.file_tag_date || null, | ||||
|       tagComposer: payload.file_tag_composer || null, | ||||
|       tagPublisher: payload.file_tag_publisher || null, | ||||
|       tagComment: payload.file_tag_comment || null, | ||||
|       tagDescription: payload.file_tag_description || null, | ||||
|       tagEncoder: payload.file_tag_encoder || null, | ||||
|       tagEncodedBy: payload.file_tag_encodedby || null, | ||||
|       tagIsbn: payload.file_tag_isbn || null, | ||||
|       tagLanguage: payload.file_tag_language || null, | ||||
|       tagASIN: payload.file_tag_asin || null | ||||
|     } | ||||
| 
 | ||||
|     var hasUpdates = false | ||||
|     for (const key in dataMap) { | ||||
|       if (dataMap[key] !== this[key]) { | ||||
|         this[key] = dataMap[key] | ||||
|         hasUpdates = true | ||||
|       } | ||||
|     } | ||||
|     return hasUpdates | ||||
|   } | ||||
| 
 | ||||
|   isEqual(audioFileMetadata) { | ||||
|     if (!audioFileMetadata || !audioFileMetadata.toJSON) return false | ||||
|     for (const key in audioFileMetadata.toJSON()) { | ||||
|       if (audioFileMetadata[key] !== this[key]) return false | ||||
|     } | ||||
|     return true | ||||
|   } | ||||
| } | ||||
| module.exports = AudioFileMetadata | ||||
| @ -17,15 +17,15 @@ class LibraryItem { | ||||
|     this.ctimeMs = null | ||||
|     this.birthtimeMs = null | ||||
|     this.addedAt = null | ||||
|     this.lastUpdate = null | ||||
|     this.updatedAt = null | ||||
|     this.lastScan = null | ||||
|     this.scanVersion = null | ||||
| 
 | ||||
|     // Entity was scanned and not found
 | ||||
|     this.isMissing = false | ||||
| 
 | ||||
|     this.entityType = null | ||||
|     this.entity = null | ||||
|     this.mediaType = null | ||||
|     this.media = null | ||||
| 
 | ||||
|     this.libraryFiles = [] | ||||
| 
 | ||||
| @ -45,17 +45,17 @@ class LibraryItem { | ||||
|     this.ctimeMs = libraryItem.ctimeMs || 0 | ||||
|     this.birthtimeMs = libraryItem.birthtimeMs || 0 | ||||
|     this.addedAt = libraryItem.addedAt | ||||
|     this.lastUpdate = libraryItem.lastUpdate || this.addedAt | ||||
|     this.updatedAt = libraryItem.updatedAt || this.addedAt | ||||
|     this.lastScan = libraryItem.lastScan || null | ||||
|     this.scanVersion = libraryItem.scanVersion || null | ||||
| 
 | ||||
|     this.isMissing = !!libraryItem.isMissing | ||||
| 
 | ||||
|     this.entityType = libraryItem.entityType | ||||
|     if (this.entityType === 'book') { | ||||
|       this.entity = new Book(libraryItem.entity) | ||||
|     } else if (this.entityType === 'podcast') { | ||||
|       this.entity = new Podcast(libraryItem.entity) | ||||
|     this.mediaType = libraryItem.mediaType | ||||
|     if (this.mediaType === 'book') { | ||||
|       this.media = new Book(libraryItem.media) | ||||
|     } else if (this.mediaType === 'podcast') { | ||||
|       this.media = new Podcast(libraryItem.media) | ||||
|     } | ||||
| 
 | ||||
|     this.libraryFiles = libraryItem.libraryFiles.map(f => new LibraryFile(f)) | ||||
| @ -73,14 +73,64 @@ class LibraryItem { | ||||
|       ctimeMs: this.ctimeMs, | ||||
|       birthtimeMs: this.birthtimeMs, | ||||
|       addedAt: this.addedAt, | ||||
|       lastUpdate: this.lastUpdate, | ||||
|       updatedAt: this.updatedAt, | ||||
|       lastScan: this.lastScan, | ||||
|       scanVersion: this.scanVersion, | ||||
|       isMissing: !!this.isMissing, | ||||
|       entityType: this.entityType, | ||||
|       entity: this.entity.toJSON(), | ||||
|       mediaType: this.mediaType, | ||||
|       media: this.media.toJSON(), | ||||
|       libraryFiles: this.libraryFiles.map(f => f.toJSON()) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONMinified() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       ino: this.ino, | ||||
|       libraryId: this.libraryId, | ||||
|       folderId: this.folderId, | ||||
|       path: this.path, | ||||
|       relPath: this.relPath, | ||||
|       mtimeMs: this.mtimeMs, | ||||
|       ctimeMs: this.ctimeMs, | ||||
|       birthtimeMs: this.birthtimeMs, | ||||
|       addedAt: this.addedAt, | ||||
|       updatedAt: this.updatedAt, | ||||
|       isMissing: !!this.isMissing, | ||||
|       mediaType: this.mediaType, | ||||
|       media: this.media.toJSONMinified(), | ||||
|       numFiles: this.libraryFiles.length | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   // Adds additional helpful fields like media duration, tracks, etc.
 | ||||
|   toJSONExpanded() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       ino: this.ino, | ||||
|       libraryId: this.libraryId, | ||||
|       folderId: this.folderId, | ||||
|       path: this.path, | ||||
|       relPath: this.relPath, | ||||
|       mtimeMs: this.mtimeMs, | ||||
|       ctimeMs: this.ctimeMs, | ||||
|       birthtimeMs: this.birthtimeMs, | ||||
|       addedAt: this.addedAt, | ||||
|       updatedAt: this.updatedAt, | ||||
|       lastScan: this.lastScan, | ||||
|       scanVersion: this.scanVersion, | ||||
|       isMissing: !!this.isMissing, | ||||
|       mediaType: this.mediaType, | ||||
|       media: this.media.toJSONExpanded(), | ||||
|       libraryFiles: this.libraryFiles.map(f => f.toJSON()), | ||||
|       size: this.size | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get size() { | ||||
|     var total = 0 | ||||
|     this.libraryFiles.forEach((lf) => total += lf.metadata.size) | ||||
|     return total | ||||
|   } | ||||
| } | ||||
| module.exports = LibraryItem | ||||
| @ -7,7 +7,6 @@ class Book { | ||||
|     this.metadata = null | ||||
| 
 | ||||
|     this.coverPath = null | ||||
|     this.relCoverPath = null | ||||
|     this.tags = [] | ||||
|     this.audioFiles = [] | ||||
|     this.ebookFiles = [] | ||||
| @ -21,7 +20,6 @@ class Book { | ||||
|   construct(book) { | ||||
|     this.metadata = new BookMetadata(book.metadata) | ||||
|     this.coverPath = book.coverPath | ||||
|     this.relCoverPath = book.relCoverPath | ||||
|     this.tags = [...book.tags] | ||||
|     this.audioFiles = book.audioFiles.map(f => new AudioFile(f)) | ||||
|     this.ebookFiles = book.ebookFiles.map(f => new EBookFile(f)) | ||||
| @ -32,12 +30,53 @@ class Book { | ||||
|     return { | ||||
|       metadata: this.metadata.toJSON(), | ||||
|       coverPath: this.coverPath, | ||||
|       relCoverPath: this.relCoverPath, | ||||
|       tags: [...this.tags], | ||||
|       audioFiles: this.audioFiles.map(f => f.toJSON()), | ||||
|       ebookFiles: this.ebookFiles.map(f => f.toJSON()), | ||||
|       chapters: this.chapters.map(c => ({ ...c })) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONMinified() { | ||||
|     return { | ||||
|       metadata: this.metadata.toJSON(), | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       numTracks: this.tracks.length, | ||||
|       numAudioFiles: this.audioFiles.length, | ||||
|       numEbooks: this.ebookFiles.length, | ||||
|       numChapters: this.chapters.length, | ||||
|       duration: this.duration, | ||||
|       size: this.size | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONExpanded() { | ||||
|     return { | ||||
|       metadata: this.metadata.toJSONExpanded(), | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       audioFiles: this.audioFiles.map(f => f.toJSON()), | ||||
|       ebookFiles: this.ebookFiles.map(f => f.toJSON()), | ||||
|       chapters: this.chapters.map(c => ({ ...c })), | ||||
|       duration: this.duration, | ||||
|       size: this.size, | ||||
|       tracks: this.tracks.map(t => t.toJSON()) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get tracks() { | ||||
|     return this.audioFiles.filter(af => !af.exclude && !af.invalid) | ||||
|   } | ||||
|   get duration() { | ||||
|     var total = 0 | ||||
|     this.tracks.forEach((track) => total += track.duration) | ||||
|     return total | ||||
|   } | ||||
|   get size() { | ||||
|     var total = 0 | ||||
|     this.audioFiles.forEach((af) => total += af.metadata.size) | ||||
|     return total | ||||
|   } | ||||
| } | ||||
| module.exports = Book | ||||
| @ -6,8 +6,8 @@ class Podcast { | ||||
|     this.id = null | ||||
| 
 | ||||
|     this.metadata = null | ||||
|     this.cover = null | ||||
|     this.coverFullPath = null | ||||
|     this.coverPath = null | ||||
|     this.tags = [] | ||||
|     this.episodes = [] | ||||
| 
 | ||||
|     this.createdAt = null | ||||
| @ -21,8 +21,8 @@ class Podcast { | ||||
|   construct(podcast) { | ||||
|     this.id = podcast.id | ||||
|     this.metadata = new PodcastMetadata(podcast.metadata) | ||||
|     this.cover = podcast.cover | ||||
|     this.coverFullPath = podcast.coverFullPath | ||||
|     this.coverPath = podcast.coverPath | ||||
|     this.tags = [...podcast.tags] | ||||
|     this.episodes = podcast.episodes.map((e) => new PodcastEpisode(e)) | ||||
|     this.createdAt = podcast.createdAt | ||||
|     this.lastUpdate = podcast.lastUpdate | ||||
| @ -32,8 +32,32 @@ class Podcast { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       metadata: this.metadata.toJSON(), | ||||
|       cover: this.cover, | ||||
|       coverFullPath: this.coverFullPath, | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       episodes: this.episodes.map(e => e.toJSON()), | ||||
|       createdAt: this.createdAt, | ||||
|       lastUpdate: this.lastUpdate | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONMinified() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       metadata: this.metadata.toJSON(), | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       episodes: this.episodes.map(e => e.toJSON()), | ||||
|       createdAt: this.createdAt, | ||||
|       lastUpdate: this.lastUpdate | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONExpanded() { | ||||
|     return { | ||||
|       id: this.id, | ||||
|       metadata: this.metadata.toJSONExpanded(), | ||||
|       coverPath: this.coverPath, | ||||
|       tags: [...this.tags], | ||||
|       episodes: this.episodes.map(e => e.toJSON()), | ||||
|       createdAt: this.createdAt, | ||||
|       lastUpdate: this.lastUpdate | ||||
|  | ||||
| @ -1,3 +1,4 @@ | ||||
| const globals = require('../../utils/globals') | ||||
| const FileMetadata = require('../metadata/FileMetadata') | ||||
| 
 | ||||
| class LibraryFile { | ||||
| @ -24,8 +25,18 @@ class LibraryFile { | ||||
|       ino: this.ino, | ||||
|       metadata: this.metadata.toJSON(), | ||||
|       addedAt: this.addedAt, | ||||
|       updatedAt: this.updatedAt | ||||
|       updatedAt: this.updatedAt, | ||||
|       fileType: this.fileType | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get fileType() { | ||||
|     if (globals.SupportedImageTypes.includes(this.metadata.format)) return 'image' | ||||
|     if (globals.SupportedAudioTypes.includes(this.metadata.format)) return 'audio' | ||||
|     if (globals.SupportedEbookTypes.includes(this.metadata.format)) return 'ebook' | ||||
|     if (globals.TextFileTypes.includes(this.metadata.format)) return 'text' | ||||
|     if (globals.MetadataFileTypes.includes(this.metadata.format)) return 'metadata' | ||||
|     return 'unknown' | ||||
|   } | ||||
| } | ||||
| module.exports = LibraryFile | ||||
| @ -1,5 +1,5 @@ | ||||
| const { isNullOrNaN } = require('../utils/index') | ||||
| const AudioFileMetadata = require('./metadata/AudioMetaTags') | ||||
| const { isNullOrNaN } = require('../../utils/index') | ||||
| const AudioFileMetadata = require('../metadata/AudioMetaTags') | ||||
| 
 | ||||
| class AudioFile { | ||||
|   constructor(data) { | ||||
| @ -1,4 +1,4 @@ | ||||
| var { bytesPretty } = require('../utils/fileUtils') | ||||
| var { bytesPretty } = require('../../utils/fileUtils') | ||||
| 
 | ||||
| class AudioTrack { | ||||
|   constructor(audioTrack = null) { | ||||
| @ -1,12 +1,12 @@ | ||||
| const Path = require('path') | ||||
| const fs = require('fs-extra') | ||||
| const { bytesPretty, readTextFile, getIno } = require('../utils/fileUtils') | ||||
| const { comparePaths, getId, elapsedPretty } = require('../utils/index') | ||||
| const { parseOpfMetadataXML } = require('../utils/parseOpfMetadata') | ||||
| const { extractCoverArt } = require('../utils/ffmpegHelpers') | ||||
| const nfoGenerator = require('../utils/nfoGenerator') | ||||
| const abmetadataGenerator = require('../utils/abmetadataGenerator') | ||||
| const Logger = require('../Logger') | ||||
| const { bytesPretty, readTextFile, getIno } = require('../../utils/fileUtils') | ||||
| const { comparePaths, getId, elapsedPretty } = require('../../utils/index') | ||||
| const { parseOpfMetadataXML } = require('../../utils/parseOpfMetadata') | ||||
| const { extractCoverArt } = require('../../utils/ffmpegHelpers') | ||||
| const nfoGenerator = require('../../utils/nfoGenerator') | ||||
| const abmetadataGenerator = require('../../utils/abmetadataGenerator') | ||||
| const Logger = require('../../Logger') | ||||
| const Book = require('./Book') | ||||
| const AudioTrack = require('./AudioTrack') | ||||
| const AudioFile = require('./AudioFile') | ||||
| @ -1,5 +1,5 @@ | ||||
| const { getId } = require('../utils/index') | ||||
| const Logger = require('../Logger') | ||||
| const { getId } = require('../../utils/index') | ||||
| const Logger = require('../../Logger') | ||||
| 
 | ||||
| class Author { | ||||
|   constructor(author = null) { | ||||
| @ -1,6 +1,6 @@ | ||||
| const Path = require('path') | ||||
| const Logger = require('../Logger') | ||||
| const parseAuthors = require('../utils/parseAuthors') | ||||
| const Logger = require('../../Logger') | ||||
| const parseAuthors = require('../../utils/parseAuthors') | ||||
| 
 | ||||
| class Book { | ||||
|   constructor(book = null) { | ||||
| @ -13,6 +13,7 @@ class BookMetadata { | ||||
|     this.isbn = null | ||||
|     this.asin = null | ||||
|     this.language = null | ||||
|     this.explicit = false | ||||
| 
 | ||||
|     if (metadata) { | ||||
|       this.construct(metadata) | ||||
| @ -33,6 +34,7 @@ class BookMetadata { | ||||
|     this.isbn = metadata.isbn | ||||
|     this.asin = metadata.asin | ||||
|     this.language = metadata.language | ||||
|     this.explicit = metadata.explicit | ||||
|   } | ||||
| 
 | ||||
|   toJSON() { | ||||
| @ -49,8 +51,54 @@ class BookMetadata { | ||||
|       description: this.description, | ||||
|       isbn: this.isbn, | ||||
|       asin: this.asin, | ||||
|       language: this.language | ||||
|       language: this.language, | ||||
|       explicit: this.explicit | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONExpanded() { | ||||
|     return { | ||||
|       title: this.title, | ||||
|       subtitle: this.subtitle, | ||||
|       authors: this.authors.map(a => ({ ...a })), // Author JSONMinimal with name and id
 | ||||
|       narrators: [...this.narrators], | ||||
|       series: this.series.map(s => ({ ...s })), | ||||
|       genres: [...this.genres], | ||||
|       publishedYear: this.publishedYear, | ||||
|       publishedDate: this.publishedDate, | ||||
|       publisher: this.publisher, | ||||
|       description: this.description, | ||||
|       isbn: this.isbn, | ||||
|       asin: this.asin, | ||||
|       language: this.language, | ||||
|       explicit: this.explicit, | ||||
|       authorName: this.authorName, | ||||
|       narratorName: this.narratorName | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   get titleIgnorePrefix() { | ||||
|     if (!this.title) return '' | ||||
|     if (this.title.toLowerCase().startsWith('the ')) { | ||||
|       return this.title.substr(4) + ', The' | ||||
|     } | ||||
|     return this.title | ||||
|   } | ||||
|   get authorName() { | ||||
|     return this.authors.map(au => au.name).join(', ') | ||||
|   } | ||||
|   get narratorName() { | ||||
|     return this.narrators.join(', ') | ||||
|   } | ||||
| 
 | ||||
|   hasAuthor(authorName) { | ||||
|     return !!this.authors.find(au => au.name == authorName) | ||||
|   } | ||||
|   hasSeries(seriesName) { | ||||
|     return !!this.series.find(se => se.name == seriesName) | ||||
|   } | ||||
|   hasNarrator(narratorName) { | ||||
|     return this.narrators.includes(narratorName) | ||||
|   } | ||||
| } | ||||
| module.exports = BookMetadata | ||||
| @ -41,5 +41,10 @@ class FileMetadata { | ||||
|   clone() { | ||||
|     return new FileMetadata(this.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   get format() { | ||||
|     if (!this.ext) return '' | ||||
|     return this.ext.slice(1) | ||||
|   } | ||||
| } | ||||
| module.exports = FileMetadata | ||||
| @ -9,6 +9,7 @@ class PodcastMetadata { | ||||
|     this.itunesPageUrl = null | ||||
|     this.itunesId = null | ||||
|     this.itunesArtistId = null | ||||
|     this.explicit = false | ||||
| 
 | ||||
|     if (metadata) { | ||||
|       this.construct(metadata) | ||||
| @ -25,6 +26,7 @@ class PodcastMetadata { | ||||
|     this.itunesPageUrl = metadata.itunesPageUrl | ||||
|     this.itunesId = metadata.itunesId | ||||
|     this.itunesArtistId = metadata.itunesArtistId | ||||
|     this.explicit = metadata.explicit | ||||
|   } | ||||
| 
 | ||||
|   toJSON() { | ||||
| @ -38,7 +40,12 @@ class PodcastMetadata { | ||||
|       itunesPageUrl: this.itunesPageUrl, | ||||
|       itunesId: this.itunesId, | ||||
|       itunesArtistId: this.itunesArtistId, | ||||
|       explicit: this.explicit | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   toJSONExpanded() { | ||||
|     return this.toJSON() | ||||
|   } | ||||
| } | ||||
| module.exports = PodcastMetadata | ||||
| @ -1,4 +1,4 @@ | ||||
| const AudioFileMetadata = require('../objects/AudioFileMetadata') | ||||
| const AudioFileMetadata = require('../objects/metadata/AudioMetaTags') | ||||
| 
 | ||||
| class AudioProbeData { | ||||
|   constructor() { | ||||
|  | ||||
| @ -10,7 +10,7 @@ const { ScanResult, LogLevel } = require('../utils/constants') | ||||
| 
 | ||||
| const AudioFileScanner = require('./AudioFileScanner') | ||||
| const BookFinder = require('../finders/BookFinder') | ||||
| const Audiobook = require('../objects/Audiobook') | ||||
| const Audiobook = require('../objects/legacy/Audiobook') | ||||
| const LibraryScan = require('./LibraryScan') | ||||
| const ScanOptions = require('./ScanOptions') | ||||
| 
 | ||||
|  | ||||
| @ -3,7 +3,7 @@ const fs = require('fs-extra') | ||||
| const njodb = require("njodb") | ||||
| 
 | ||||
| const { SupportedEbookTypes } = require('./globals') | ||||
| const Audiobook = require('../objects/Audiobook') | ||||
| const Audiobook = require('../objects/legacy/Audiobook') | ||||
| const LibraryItem = require('../objects/LibraryItem') | ||||
| 
 | ||||
| const Logger = require('../Logger') | ||||
| @ -142,7 +142,7 @@ function makeLibraryItemFromOldAb(audiobook) { | ||||
|   libraryItem.lastScan = audiobook.lastScan | ||||
|   libraryItem.scanVersion = audiobook.scanVersion | ||||
|   libraryItem.isMissing = audiobook.isMissing | ||||
|   libraryItem.entityType = 'book' | ||||
|   libraryItem.mediaType = 'book' | ||||
| 
 | ||||
|   var bookEntity = new Book() | ||||
|   var bookMetadata = new BookMetadata(audiobook.book) | ||||
| @ -159,8 +159,6 @@ function makeLibraryItemFromOldAb(audiobook) { | ||||
| 
 | ||||
|   bookEntity.metadata = bookMetadata | ||||
|   bookEntity.coverPath = audiobook.book.coverFullPath | ||||
|   // Path relative to library item
 | ||||
|   bookEntity.relCoverPath = getRelativePath(audiobook.book.coverFullPath, audiobook.fullPath) | ||||
|   bookEntity.tags = [...audiobook.tags] | ||||
| 
 | ||||
|   var payload = makeFilesFromOldAb(audiobook) | ||||
| @ -171,7 +169,7 @@ function makeLibraryItemFromOldAb(audiobook) { | ||||
|     bookEntity.chapters = audiobook.chapters.map(c => ({ ...c })) | ||||
|   } | ||||
| 
 | ||||
|   libraryItem.entity = bookEntity | ||||
|   libraryItem.media = bookEntity | ||||
|   libraryItem.libraryFiles = payload.libraryFiles | ||||
|   return libraryItem | ||||
| } | ||||
|  | ||||
| @ -1,7 +1,9 @@ | ||||
| const globals = { | ||||
|   SupportedImageTypes: ['png', 'jpg', 'jpeg', 'webp'], | ||||
|   SupportedAudioTypes: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'mp4', 'aac'], | ||||
|   SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'] | ||||
|   SupportedEbookTypes: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'], | ||||
|   TextFileTypes: ['txt', 'nfo'], | ||||
|   MetadataFileTypes: ['opf', 'abs'] | ||||
| } | ||||
| 
 | ||||
| module.exports = globals | ||||
|  | ||||
| @ -8,6 +8,45 @@ module.exports = { | ||||
|     return Buffer.from(decodeURIComponent(text), 'base64').toString() | ||||
|   }, | ||||
| 
 | ||||
|   getFilteredLibraryItems(libraryItems, filterBy, user) { | ||||
|     var filtered = libraryItems | ||||
| 
 | ||||
|     var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'languages'] | ||||
|     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(li => li.media.metadata && li.media.metadata.genres.includes(filter)) | ||||
|       else if (group === 'tags') filtered = filtered.filter(li => li.media.tags.includes(filter)) | ||||
|       else if (group === 'series') { | ||||
|         if (filter === 'No Series') filtered = filtered.filter(li => li.media.metadata && !li.media.metadata.series.length) | ||||
|         else filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasSeries(filter)) | ||||
|       } | ||||
|       else if (group === 'authors') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasAuthor(filter)) | ||||
|       else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter)) | ||||
|       else if (group === 'progress') { | ||||
|         filtered = filtered.filter(li => { | ||||
|           var userAudiobook = user.getAudiobookJSON(li.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 (group === 'languages') { | ||||
|         filtered = filtered.filter(li => li.media.metadata && li.media.metadata.language === filter) | ||||
|       } | ||||
|     } else if (filterBy === 'issues') { | ||||
|       filtered = filtered.filter(ab => { | ||||
|         // TODO: Update filter for issues
 | ||||
|         return ab.isMissing | ||||
|         // return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
 | ||||
|       }) | ||||
|     } | ||||
| 
 | ||||
|     return filtered | ||||
|   }, | ||||
| 
 | ||||
|   getFiltered(audiobooks, filterBy, user) { | ||||
|     var filtered = audiobooks | ||||
| 
 | ||||
| @ -45,6 +84,55 @@ module.exports = { | ||||
|     return filtered | ||||
|   }, | ||||
| 
 | ||||
|   getDistinctFilterDataNew(libraryItems) { | ||||
|     var data = { | ||||
|       authors: [], | ||||
|       genres: [], | ||||
|       tags: [], | ||||
|       series: [], | ||||
|       narrators: [], | ||||
|       languages: [] | ||||
|     } | ||||
|     libraryItems.forEach((li) => { | ||||
|       var mediaMetadata = li.media.metadata | ||||
|       if (mediaMetadata.authors.length) { | ||||
|         mediaMetadata.authors.forEach((author) => { | ||||
|           if (author && !data.authors.includes(author.name)) data.authors.push(author.name) | ||||
|         }) | ||||
|       } | ||||
|       if (mediaMetadata.series.length) { | ||||
|         mediaMetadata.series.forEach((series) => { | ||||
|           if (series && !data.series.includes(series.name)) data.series.push(series.name) | ||||
|         }) | ||||
|       } | ||||
|       if (mediaMetadata.genres.length) { | ||||
|         mediaMetadata.genres.forEach((genre) => { | ||||
|           if (genre && !data.genres.includes(genre)) data.genres.push(genre) | ||||
|         }) | ||||
|       } | ||||
|       if (li.media.tags.length) { | ||||
|         li.media.tags.forEach((tag) => { | ||||
|           if (tag && !data.tags.includes(tag)) data.tags.push(tag) | ||||
|         }) | ||||
|       } | ||||
|       if (mediaMetadata.narrators.length) { | ||||
|         mediaMetadata.narrators.forEach((narrator) => { | ||||
|           if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator) | ||||
|         }) | ||||
|       } | ||||
|       if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) data.languages.push(mediaMetadata.language) | ||||
|     }) | ||||
|     data.authors = naturalSort(data.authors).asc() | ||||
|     data.genres = naturalSort(data.genres).asc() | ||||
|     data.tags = naturalSort(data.tags).asc() | ||||
|     data.series = naturalSort(data.series).asc() | ||||
|     data.narrators = naturalSort(data.narrators).asc() | ||||
|     data.languages = naturalSort(data.languages).asc() | ||||
|     return data | ||||
|   }, | ||||
| 
 | ||||
| 
 | ||||
|   // TODO: Remove legacy
 | ||||
|   getDistinctFilterData(audiobooks) { | ||||
|     var data = { | ||||
|       authors: [], | ||||
| @ -246,9 +334,11 @@ module.exports = { | ||||
|     return totalSize | ||||
|   }, | ||||
| 
 | ||||
|   getNumIssues(books) { | ||||
|     return books.filter(ab => { | ||||
|       return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid | ||||
|     }).length | ||||
|   getNumIssues(libraryItems) { | ||||
|     // TODO: Implement issues
 | ||||
|     return libraryItems.filter(li => li.isMissing).length | ||||
|     // return books.filter(ab => {
 | ||||
|     //   return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
 | ||||
|     // }).length
 | ||||
|   } | ||||
| } | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user