mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Music albums grouping and page
This commit is contained in:
		
							parent
							
								
									9de7be1cb4
								
							
						
					
					
						commit
						d6da161b13
					
				| @ -213,11 +213,16 @@ export default { | ||||
|     isAuthorsPage() { | ||||
|       return this.$route.name === 'library-library-authors' | ||||
|     }, | ||||
|     isAlbumsPage() { | ||||
|       return this.page === 'albums' | ||||
|     }, | ||||
|     numShowing() { | ||||
|       return this.totalEntities | ||||
|     }, | ||||
|     entityName() { | ||||
|       if (this.isAlbumsPage) return 'Albums' | ||||
|       if (this.isMusicLibrary) return 'Tracks' | ||||
| 
 | ||||
|       if (this.isPodcastLibrary) return this.$strings.LabelPodcasts | ||||
|       if (!this.page) return this.$strings.LabelBooks | ||||
|       if (this.isSeriesPage) return this.$strings.LabelSeries | ||||
|  | ||||
| @ -205,7 +205,7 @@ export default { | ||||
|       return this.$store.state.globals.selectedMediaItems || [] | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       var baseSize = this.isCoverSquareAspectRatio ? 192 : 120 | ||||
|       const baseSize = this.isCoverSquareAspectRatio ? 192 : 120 | ||||
|       return this.entityWidth / baseSize | ||||
|     } | ||||
|   }, | ||||
| @ -215,7 +215,7 @@ export default { | ||||
|     }, | ||||
|     editEntity(entity) { | ||||
|       if (this.entityName === 'books' || this.entityName === 'series-books') { | ||||
|         var bookIds = this.entities.map((e) => e.id) | ||||
|         const bookIds = this.entities.map((e) => e.id) | ||||
|         this.$store.commit('setBookshelfBookIds', bookIds) | ||||
|         this.$store.commit('showEditModal', entity) | ||||
|       } else if (this.entityName === 'collections') { | ||||
| @ -308,7 +308,7 @@ export default { | ||||
|       } | ||||
|     }, | ||||
|     async fetchEntites(page = 0) { | ||||
|       var startIndex = page * this.booksPerFetch | ||||
|       const startIndex = page * this.booksPerFetch | ||||
| 
 | ||||
|       this.isFetchingEntities = true | ||||
| 
 | ||||
|  | ||||
| @ -70,6 +70,14 @@ | ||||
|       <div v-show="isPodcastSearchPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> | ||||
|     </nuxt-link> | ||||
| 
 | ||||
|     <nuxt-link v-if="isMusicLibrary" :to="`/library/${currentLibraryId}/bookshelf/albums`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isMusicAlbumsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> | ||||
|       <span class="material-icons-outlined text-xl">album</span> | ||||
| 
 | ||||
|       <p class="font-book pt-1.5 text-center leading-4" style="font-size: 0.9rem">Albums</p> | ||||
| 
 | ||||
|       <div v-show="isMusicAlbumsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" /> | ||||
|     </nuxt-link> | ||||
| 
 | ||||
|     <nuxt-link v-if="showPlaylists" :to="`/library/${currentLibraryId}/bookshelf/playlists`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPlaylistsPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'"> | ||||
|       <span class="material-icons text-2.5xl">queue_music</span> | ||||
| 
 | ||||
| @ -138,12 +146,18 @@ export default { | ||||
|     isPodcastLibrary() { | ||||
|       return this.currentLibraryMediaType === 'podcast' | ||||
|     }, | ||||
|     isMusicLibrary() { | ||||
|       return this.currentLibraryMediaType === 'music' | ||||
|     }, | ||||
|     isPodcastSearchPage() { | ||||
|       return this.$route.name === 'library-library-podcast-search' | ||||
|     }, | ||||
|     isPodcastLatestPage() { | ||||
|       return this.$route.name === 'library-library-podcast-latest' | ||||
|     }, | ||||
|     isMusicAlbumsPage() { | ||||
|       return this.paramId === 'albums' | ||||
|     }, | ||||
|     homePage() { | ||||
|       return this.$route.name === 'library-library' | ||||
|     }, | ||||
|  | ||||
							
								
								
									
										110
									
								
								client/components/cards/LazyAlbumCard.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										110
									
								
								client/components/cards/LazyAlbumCard.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,110 @@ | ||||
| <template> | ||||
|   <div ref="card" :id="`album-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard"> | ||||
|     <div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" /> | ||||
|     <div class="w-full h-full bg-primary relative rounded overflow-hidden"> | ||||
|       <covers-preview-cover ref="cover" :src="coverSrc" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" /> | ||||
|     </div> | ||||
| 
 | ||||
|     <div v-if="!isAlternativeBookshelfView" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(200, width) + 'px' }"> | ||||
|       <div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }"> | ||||
|         <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> | ||||
|       </div> | ||||
|     </div> | ||||
|     <div v-else class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center"> | ||||
|       <p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| export default { | ||||
|   props: { | ||||
|     index: Number, | ||||
|     width: Number, | ||||
|     height: Number, | ||||
|     bookCoverAspectRatio: Number, | ||||
|     bookshelfView: { | ||||
|       type: Number, | ||||
|       default: 0 | ||||
|     }, | ||||
|     albumMount: { | ||||
|       type: Object, | ||||
|       default: () => null | ||||
|     } | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       album: null, | ||||
|       isSelectionMode: false, | ||||
|       selected: false, | ||||
|       isHovering: false | ||||
|     } | ||||
|   }, | ||||
|   computed: { | ||||
|     coverSrc() { | ||||
|       const config = this.$config || this.$nuxt.$config | ||||
|       if (!this.album || !this.album.libraryItemId) return `${config.routerBasePath}/book_placeholder.jpg` | ||||
|       return this.store.getters['globals/getLibraryItemCoverSrcById'](this.album.libraryItemId) | ||||
|     }, | ||||
|     labelFontSize() { | ||||
|       if (this.width < 160) return 0.75 | ||||
|       return 0.875 | ||||
|     }, | ||||
|     sizeMultiplier() { | ||||
|       if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2) | ||||
|       return this.width / 240 | ||||
|     }, | ||||
|     title() { | ||||
|       return this.album ? this.album.title : '' | ||||
|     }, | ||||
|     store() { | ||||
|       return this.$store || this.$nuxt.$store | ||||
|     }, | ||||
|     currentLibraryId() { | ||||
|       return this.store.state.libraries.currentLibraryId | ||||
|     }, | ||||
|     isAlternativeBookshelfView() { | ||||
|       const constants = this.$constants || this.$nuxt.$constants | ||||
|       return this.bookshelfView == constants.BookshelfView.DETAIL | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|     setEntity(album) { | ||||
|       this.album = album | ||||
|     }, | ||||
|     setSelectionMode(val) { | ||||
|       this.isSelectionMode = val | ||||
|     }, | ||||
|     mouseover() { | ||||
|       this.isHovering = true | ||||
|     }, | ||||
|     mouseleave() { | ||||
|       this.isHovering = false | ||||
|     }, | ||||
|     clickCard() { | ||||
|       if (!this.album) return | ||||
|       // const router = this.$router || this.$nuxt.$router | ||||
|       // router.push(`/album/${this.$encode(this.title)}`) | ||||
|     }, | ||||
|     clickEdit() { | ||||
|       this.$emit('edit', this.album) | ||||
|     }, | ||||
|     destroy() { | ||||
|       // destroy the vue listeners, etc | ||||
|       this.$destroy() | ||||
| 
 | ||||
|       // remove the element from the DOM | ||||
|       if (this.$el && this.$el.parentNode) { | ||||
|         this.$el.parentNode.removeChild(this.$el) | ||||
|       } else if (this.$el && this.$el.remove) { | ||||
|         this.$el.remove() | ||||
|       } | ||||
|     } | ||||
|   }, | ||||
|   mounted() { | ||||
|     if (this.albumMount) { | ||||
|       this.setEntity(this.albumMount) | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| @ -65,7 +65,8 @@ export default { | ||||
|       return `${this.naturalWidth}x${this.naturalHeight}px` | ||||
|     }, | ||||
|     placeholderUrl() { | ||||
|       return `${this.$config.routerBasePath}/book_placeholder.jpg` | ||||
|       const config = this.$config || this.$nuxt.$config | ||||
|       return `${config.routerBasePath}/book_placeholder.jpg` | ||||
|     } | ||||
|   }, | ||||
|   methods: { | ||||
|  | ||||
| @ -3,6 +3,7 @@ import LazyBookCard from '@/components/cards/LazyBookCard' | ||||
| import LazySeriesCard from '@/components/cards/LazySeriesCard' | ||||
| import LazyCollectionCard from '@/components/cards/LazyCollectionCard' | ||||
| import LazyPlaylistCard from '@/components/cards/LazyPlaylistCard' | ||||
| import LazyAlbumCard from '@/components/cards/LazyAlbumCard' | ||||
| 
 | ||||
| export default { | ||||
|   data() { | ||||
| @ -17,6 +18,7 @@ export default { | ||||
|       if (this.entityName === 'series') return Vue.extend(LazySeriesCard) | ||||
|       if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard) | ||||
|       if (this.entityName === 'playlists') return Vue.extend(LazyPlaylistCard) | ||||
|       if (this.entityName === 'albums') return Vue.extend(LazyAlbumCard) | ||||
|       return Vue.extend(LazyBookCard) | ||||
|     }, | ||||
|     async mountEntityCard(index) { | ||||
| @ -28,7 +30,7 @@ export default { | ||||
|       } | ||||
|       this.entityIndexesMounted.push(index) | ||||
|       if (this.entityComponentRefs[index]) { | ||||
|         var bookComponent = this.entityComponentRefs[index] | ||||
|         const bookComponent = this.entityComponentRefs[index] | ||||
|         shelfEl.appendChild(bookComponent.$el) | ||||
|         if (this.isSelectionMode) { | ||||
|           bookComponent.setSelectionMode(true) | ||||
| @ -43,13 +45,13 @@ export default { | ||||
|         bookComponent.isHovering = false | ||||
|         return | ||||
|       } | ||||
|       var shelfOffsetY = 16 | ||||
|       var row = index % this.entitiesPerShelf | ||||
|       var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft | ||||
|       const shelfOffsetY = 16 | ||||
|       const row = index % this.entitiesPerShelf | ||||
|       const shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft | ||||
| 
 | ||||
|       var ComponentClass = this.getComponentClass() | ||||
|       const ComponentClass = this.getComponentClass() | ||||
| 
 | ||||
|       var props = { | ||||
|       const props = { | ||||
|         index, | ||||
|         width: this.entityWidth, | ||||
|         height: this.entityHeight, | ||||
| @ -65,8 +67,8 @@ export default { | ||||
|         props.orderBy = this.seriesSortBy | ||||
|       } | ||||
| 
 | ||||
|       var _this = this | ||||
|       var instance = new ComponentClass({ | ||||
|       const _this = this | ||||
|       const instance = new ComponentClass({ | ||||
|         propsData: props, | ||||
|         created() { | ||||
|           this.$on('edit', (entity) => { | ||||
|  | ||||
| @ -492,6 +492,32 @@ class LibraryController { | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   // api/libraries/:id/albums
 | ||||
|   async getAlbumsForLibrary(req, res) { | ||||
|     if (!req.library.isMusic) { | ||||
|       return res.status(400).send('Invalid library media type') | ||||
|     } | ||||
| 
 | ||||
|     let libraryItems = this.db.libraryItems.filter(li => li.libraryId === req.library.id) | ||||
|     let albums = libraryHelpers.groupMusicLibraryItemsIntoAlbums(libraryItems) | ||||
|     albums = naturalSort(albums).asc(a => a.title) // Alphabetical by album title
 | ||||
| 
 | ||||
|     const payload = { | ||||
|       results: [], | ||||
|       total: albums.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 | ||||
|     } | ||||
| 
 | ||||
|     if (payload.limit) { | ||||
|       const startIndex = payload.page * payload.limit | ||||
|       albums = albums.slice(startIndex, startIndex + payload.limit) | ||||
|     } | ||||
| 
 | ||||
|     payload.results = albums | ||||
|     res.json(payload) | ||||
|   } | ||||
| 
 | ||||
|   async getLibraryFilterData(req, res) { | ||||
|     res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems)) | ||||
|   } | ||||
|  | ||||
| @ -29,6 +29,9 @@ class Library { | ||||
|   get isPodcast() { | ||||
|     return this.mediaType === 'podcast' | ||||
|   } | ||||
|   get isMusic() { | ||||
|     return this.mediaType === 'music' | ||||
|   } | ||||
| 
 | ||||
|   construct(library) { | ||||
|     this.id = library.id | ||||
|  | ||||
| @ -76,6 +76,7 @@ class ApiRouter { | ||||
|     this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/albums', LibraryController.middleware.bind(this), LibraryController.getAlbumsForLibrary.bind(this)) | ||||
|     this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.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)) | ||||
|  | ||||
| @ -1,4 +1,5 @@ | ||||
| const { sort, createNewSortInstance } = require('../libs/fastSort') | ||||
| const Logger = require('../Logger') | ||||
| const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index') | ||||
| const naturalSort = createNewSortInstance({ | ||||
|   comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare | ||||
| @ -701,5 +702,34 @@ module.exports = { | ||||
| 
 | ||||
|       return shelf | ||||
|     }) | ||||
|   }, | ||||
| 
 | ||||
|   groupMusicLibraryItemsIntoAlbums(libraryItems) { | ||||
|     const albums = {} | ||||
| 
 | ||||
|     libraryItems.forEach((li) => { | ||||
|       const albumTitle = li.media.metadata.album | ||||
|       const albumArtist = li.media.metadata.albumArtist | ||||
| 
 | ||||
|       if (albumTitle && !albums[albumTitle]) { | ||||
|         albums[albumTitle] = { | ||||
|           title: albumTitle, | ||||
|           artist: albumArtist, | ||||
|           libraryItemId: li.media.coverPath ? li.id : null, | ||||
|           numTracks: 1 | ||||
|         } | ||||
|       } else if (albumTitle && albums[albumTitle].artist === albumArtist) { | ||||
|         if (!albums[albumTitle].libraryItemId && li.media.coverPath) albums[albumTitle].libraryItemId = li.id | ||||
|         albums[albumTitle].numTracks++ | ||||
|       } else { | ||||
|         if (albumTitle) { | ||||
|           Logger.warn(`Music track "${li.media.metadata.title}" with album "${albumTitle}" has a different album artist then another track in the same album.  This track album artist is "${albumArtist}" but the album artist is already set to "${albums[albumTitle].artist}"`) | ||||
|         } | ||||
|         if (!albums['_none_']) albums['_none_'] = { title: 'No Album', artist: 'Various Artists', libraryItemId: null, numTracks: 0 } | ||||
|         albums['_none_'].numTracks++ | ||||
|       } | ||||
|     }) | ||||
| 
 | ||||
|     return Object.values(albums) | ||||
|   } | ||||
| } | ||||
| @ -361,12 +361,12 @@ function getDataFromMediaDir(libraryMediaType, folderPath, relPath, serverSettin | ||||
| // Called from Scanner.js
 | ||||
| async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, isSingleMediaItem, serverSettings = {}) { | ||||
|   libraryItemPath = libraryItemPath.replace(/\\/g, '/') | ||||
|   var folderFullPath = folder.fullPath.replace(/\\/g, '/') | ||||
|   const folderFullPath = folder.fullPath.replace(/\\/g, '/') | ||||
| 
 | ||||
|   var libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) | ||||
|   var libraryItemData = {} | ||||
|   const libraryItemDir = libraryItemPath.replace(folderFullPath, '').slice(1) | ||||
|   let libraryItemData = {} | ||||
| 
 | ||||
|   var fileItems = [] | ||||
|   let fileItems = [] | ||||
| 
 | ||||
|   if (isSingleMediaItem) { // Single media item in root of folder
 | ||||
|     fileItems = [ | ||||
| @ -388,8 +388,8 @@ async function getLibraryItemFileData(libraryMediaType, folder, libraryItemPath, | ||||
|     libraryItemData = getDataFromMediaDir(libraryMediaType, folderFullPath, libraryItemDir, serverSettings, fileNames) | ||||
|   } | ||||
| 
 | ||||
|   var libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) | ||||
|   var libraryItem = { | ||||
|   const libraryItemDirStats = await getFileTimestampsWithIno(libraryItemData.path) | ||||
|   const libraryItem = { | ||||
|     ino: libraryItemDirStats.ino, | ||||
|     mtimeMs: libraryItemDirStats.mtimeMs || 0, | ||||
|     ctimeMs: libraryItemDirStats.ctimeMs || 0, | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user