mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			257 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			257 lines
		
	
	
		
			8.7 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { createNewSortInstance } = require('../libs/fastSort')
 | |
| const Database = require('../Database')
 | |
| const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index')
 | |
| const naturalSort = createNewSortInstance({
 | |
|   comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
 | |
| })
 | |
| 
 | |
| module.exports = {
 | |
|   /**
 | |
|    *
 | |
|    * @param {import('../models/LibraryItem')[]} libraryItems
 | |
|    * @param {*} filterSeries
 | |
|    * @param {*} hideSingleBookSeries
 | |
|    * @returns
 | |
|    */
 | |
|   getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries) {
 | |
|     const _series = {}
 | |
|     const seriesToFilterOut = {}
 | |
|     libraryItems.forEach((libraryItem) => {
 | |
|       // get all book series for item that is not already filtered out
 | |
|       const allBookSeries = (libraryItem.media.series || []).filter((se) => !seriesToFilterOut[se.id])
 | |
|       if (!allBookSeries.length) return
 | |
| 
 | |
|       allBookSeries.forEach((bookSeries) => {
 | |
|         const abJson = libraryItem.toOldJSONMinified()
 | |
|         abJson.sequence = bookSeries.bookSeries.sequence
 | |
|         if (filterSeries) {
 | |
|           const series = libraryItem.media.series.find((se) => se.id === filterSeries)
 | |
|           abJson.filterSeriesSequence = series.bookSeries.sequence
 | |
|         }
 | |
|         if (!_series[bookSeries.id]) {
 | |
|           _series[bookSeries.id] = {
 | |
|             id: bookSeries.id,
 | |
|             name: bookSeries.name,
 | |
|             nameIgnorePrefix: getTitlePrefixAtEnd(bookSeries.name),
 | |
|             nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeries.name),
 | |
|             type: 'series',
 | |
|             books: [abJson],
 | |
|             totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | |
|           }
 | |
|         } else {
 | |
|           _series[bookSeries.id].books.push(abJson)
 | |
|           _series[bookSeries.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | |
|         }
 | |
|       })
 | |
|     })
 | |
| 
 | |
|     let seriesItems = Object.values(_series)
 | |
| 
 | |
|     // Library setting to hide series with only 1 book
 | |
|     if (hideSingleBookSeries) {
 | |
|       seriesItems = seriesItems.filter((se) => se.books.length > 1)
 | |
|     }
 | |
| 
 | |
|     return seriesItems.map((series) => {
 | |
|       series.books = naturalSort(series.books).asc((li) => li.sequence)
 | |
|       return series
 | |
|     })
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {import('../models/LibraryItem')[]} libraryItems
 | |
|    * @param {string} filterSeries - series id
 | |
|    * @param {boolean} hideSingleBookSeries
 | |
|    * @returns
 | |
|    */
 | |
|   collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
 | |
|     // Get series from the library items. If this list is being collapsed after filtering for a series,
 | |
|     // don't collapse that series, only books that are in other series.
 | |
|     const seriesObjects = this.getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries).filter((s) => s.id != filterSeries)
 | |
| 
 | |
|     const filteredLibraryItems = []
 | |
| 
 | |
|     libraryItems.forEach((li) => {
 | |
|       if (li.mediaType != 'book') return
 | |
| 
 | |
|       // Handle when this is the first book in a series
 | |
|       seriesObjects
 | |
|         .filter((s) => s.books[0].id == li.id)
 | |
|         .forEach((series) => {
 | |
|           // Clone the library item as we need to attach data to it, but don't
 | |
|           // want to change the global copy of the library item
 | |
|           filteredLibraryItems.push(Object.assign(Object.create(Object.getPrototypeOf(li)), li, { collapsedSeries: series }))
 | |
|         })
 | |
| 
 | |
|       // Only included books not contained in series
 | |
|       if (!seriesObjects.some((s) => s.books.some((b) => b.id == li.id))) filteredLibraryItems.push(li)
 | |
|     })
 | |
| 
 | |
|     return filteredLibraryItems
 | |
|   },
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {*} payload
 | |
|    * @param {string} seriesId
 | |
|    * @param {import('../models/User')} user
 | |
|    * @param {import('../models/Library')} library
 | |
|    * @returns {Object[]}
 | |
|    */
 | |
|   async handleCollapseSubseries(payload, seriesId, user, library) {
 | |
|     const seriesWithBooks = await Database.seriesModel.findByPk(seriesId, {
 | |
|       include: {
 | |
|         model: Database.bookModel,
 | |
|         through: {
 | |
|           attributes: ['sequence']
 | |
|         },
 | |
|         include: [
 | |
|           {
 | |
|             model: Database.libraryItemModel
 | |
|           },
 | |
|           {
 | |
|             model: Database.authorModel,
 | |
|             through: {
 | |
|               attributes: []
 | |
|             }
 | |
|           },
 | |
|           {
 | |
|             model: Database.seriesModel,
 | |
|             through: {
 | |
|               attributes: ['sequence']
 | |
|             }
 | |
|           }
 | |
|         ]
 | |
|       }
 | |
|     })
 | |
|     if (!seriesWithBooks) {
 | |
|       payload.total = 0
 | |
|       return []
 | |
|     }
 | |
| 
 | |
|     const books = seriesWithBooks.books
 | |
|     payload.total = books.length
 | |
| 
 | |
|     let libraryItems = books
 | |
|       .map((book) => {
 | |
|         const libraryItem = book.libraryItem
 | |
|         delete book.libraryItem
 | |
|         libraryItem.media = book
 | |
|         return libraryItem
 | |
|       })
 | |
|       .filter((li) => {
 | |
|         return user.checkCanAccessLibraryItem(li)
 | |
|       })
 | |
| 
 | |
|     const collapsedItems = this.collapseBookSeries(libraryItems, seriesId, library.settings.hideSingleBookSeries)
 | |
|     if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
 | |
|       libraryItems = collapsedItems
 | |
|       payload.total = libraryItems.length
 | |
|     }
 | |
| 
 | |
|     const sortingIgnorePrefix = Database.serverSettings.sortingIgnorePrefix
 | |
| 
 | |
|     let sortArray = []
 | |
|     const direction = payload.sortDesc ? 'desc' : 'asc'
 | |
|     if (!payload.sortBy || payload.sortBy === 'sequence') {
 | |
|       sortArray = [
 | |
|         {
 | |
|           [direction]: (li) => {
 | |
|             const series = li.media.series.find((se) => se.id === seriesId)
 | |
|             return series.bookSeries.sequence
 | |
|           }
 | |
|         },
 | |
|         {
 | |
|           // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
 | |
|           [direction]: (li) => {
 | |
|             if (sortingIgnorePrefix) {
 | |
|               return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
 | |
|             } else {
 | |
|               return li.collapsedSeries?.name || li.media.title
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|       ]
 | |
|     } else {
 | |
|       // If series are collapsed and not sorting by title or sequence,
 | |
|       // sort all collapsed series to the end in alphabetical order
 | |
|       if (payload.sortBy !== 'media.metadata.title') {
 | |
|         sortArray.push({
 | |
|           asc: (li) => {
 | |
|             if (li.collapsedSeries) {
 | |
|               return sortingIgnorePrefix ? li.collapsedSeries.nameIgnorePrefix : li.collapsedSeries.name
 | |
|             } else {
 | |
|               return ''
 | |
|             }
 | |
|           }
 | |
|         })
 | |
|       }
 | |
|       sortArray.push({
 | |
|         [direction]: (li) => {
 | |
|           if (payload.sortBy === 'media.metadata.title') {
 | |
|             if (sortingIgnorePrefix) {
 | |
|               return li.collapsedSeries?.nameIgnorePrefix || li.media.titleIgnorePrefix
 | |
|             } else {
 | |
|               return li.collapsedSeries?.name || li.media.title
 | |
|             }
 | |
|           } else {
 | |
|             return payload.sortBy.split('.').reduce((a, b) => a[b], li)
 | |
|           }
 | |
|         }
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     libraryItems = naturalSort(libraryItems).by(sortArray)
 | |
| 
 | |
|     if (payload.limit) {
 | |
|       const startIndex = payload.page * payload.limit
 | |
|       libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
 | |
|     }
 | |
| 
 | |
|     return Promise.all(
 | |
|       libraryItems.map(async (li) => {
 | |
|         const filteredSeries = li.media.series.find((se) => se.id === seriesId)
 | |
|         const json = li.toOldJSONMinified()
 | |
|         json.media.metadata.series = {
 | |
|           id: filteredSeries.id,
 | |
|           name: filteredSeries.name,
 | |
|           sequence: filteredSeries.bookSeries.sequence
 | |
|         }
 | |
| 
 | |
|         if (li.collapsedSeries) {
 | |
|           json.collapsedSeries = {
 | |
|             id: li.collapsedSeries.id,
 | |
|             name: li.collapsedSeries.name,
 | |
|             nameIgnorePrefix: li.collapsedSeries.nameIgnorePrefix,
 | |
|             libraryItemIds: li.collapsedSeries.books.map((b) => b.id),
 | |
|             numBooks: li.collapsedSeries.books.length
 | |
|           }
 | |
| 
 | |
|           // If collapsing by series and filtering by a series, generate the list of sequences the collapsed
 | |
|           // series represents in the filtered series
 | |
|           json.collapsedSeries.seriesSequenceList = naturalSort(li.collapsedSeries.books.filter((b) => b.filterSeriesSequence).map((b) => b.filterSeriesSequence))
 | |
|             .asc()
 | |
|             .reduce((ranges, currentSequence) => {
 | |
|               let lastRange = ranges.at(-1)
 | |
|               let isNumber = /^(\d+|\d+\.\d*|\d*\.\d+)$/.test(currentSequence)
 | |
|               if (isNumber) currentSequence = parseFloat(currentSequence)
 | |
| 
 | |
|               if (lastRange && isNumber && lastRange.isNumber && lastRange.end + 1 == currentSequence) {
 | |
|                 lastRange.end = currentSequence
 | |
|               } else {
 | |
|                 ranges.push({ start: currentSequence, end: currentSequence, isNumber: isNumber })
 | |
|               }
 | |
| 
 | |
|               return ranges
 | |
|             }, [])
 | |
|             .map((r) => (r.start == r.end ? r.start : `${r.start}-${r.end}`))
 | |
|             .join(', ')
 | |
|         }
 | |
| 
 | |
|         return json
 | |
|       })
 | |
|     )
 | |
|   }
 | |
| }
 |