mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-02 18:37:01 -05:00 
			
		
		
		
	Replace old items filter/sort api endpoint with new, handle collapse sub-series
This commit is contained in:
		
							parent
							
								
									b1c07834be
								
							
						
					
					
						commit
						03115e5e53
					
				@ -314,11 +314,6 @@ export default {
 | 
				
			|||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
 | 
					      let entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
 | 
				
			||||||
      // TODO: Temp use new library items API for everything except collapse sub-series
 | 
					 | 
				
			||||||
      if (entityPath === 'items' && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) {
 | 
					 | 
				
			||||||
        entityPath += '2'
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
 | 
					      const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
 | 
				
			||||||
      const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
 | 
					      const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -219,7 +219,7 @@ export default {
 | 
				
			|||||||
      return this.mediaMetadata.series
 | 
					      return this.mediaMetadata.series
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    seriesSequence() {
 | 
					    seriesSequence() {
 | 
				
			||||||
      return this.series ? this.series.sequence : null
 | 
					      return this.series?.sequence || null
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    libraryId() {
 | 
					    libraryId() {
 | 
				
			||||||
      return this._libraryItem.libraryId
 | 
					      return this._libraryItem.libraryId
 | 
				
			||||||
 | 
				
			|||||||
@ -262,7 +262,7 @@ class LibraryController {
 | 
				
			|||||||
    return res.json(libraryJson)
 | 
					    return res.json(libraryJson)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getLibraryItemsNew(req, res) {
 | 
					  async getLibraryItems(req, res) {
 | 
				
			||||||
    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
					    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const payload = {
 | 
					    const payload = {
 | 
				
			||||||
@ -280,203 +280,15 @@ class LibraryController {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
    payload.offset = payload.page * payload.limit
 | 
					    payload.offset = payload.page * payload.limit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
 | 
					    // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries
 | 
				
			||||||
    payload.results = libraryItems
 | 
					    if (payload.filterBy?.split('.')[0] === 'series' && payload.collapseseries) {
 | 
				
			||||||
    payload.total = count
 | 
					      const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1])
 | 
				
			||||||
 | 
					      payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library)
 | 
				
			||||||
    res.json(payload)
 | 
					    } else {
 | 
				
			||||||
  }
 | 
					      const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
 | 
				
			||||||
 | 
					      payload.results = libraryItems
 | 
				
			||||||
  /**
 | 
					      payload.total = count
 | 
				
			||||||
   * GET: /api/libraries/:id/items
 | 
					 | 
				
			||||||
   * TODO: Remove after implementing getLibraryItemsNew
 | 
					 | 
				
			||||||
   * @param {*} req 
 | 
					 | 
				
			||||||
   * @param {*} res 
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async getLibraryItems(req, res) {
 | 
					 | 
				
			||||||
    let libraryItems = req.libraryItems
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const 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,
 | 
					 | 
				
			||||||
      mediaType: req.library.mediaType,
 | 
					 | 
				
			||||||
      minified: req.query.minified === '1',
 | 
					 | 
				
			||||||
      collapseseries: req.query.collapseseries === '1',
 | 
					 | 
				
			||||||
      include: include.join(',')
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const mediaIsBook = payload.mediaType === 'book'
 | 
					 | 
				
			||||||
    const mediaIsPodcast = payload.mediaType === 'podcast'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Step 1 - Filter the retrieved library items
 | 
					 | 
				
			||||||
    let filterSeries = null
 | 
					 | 
				
			||||||
    if (payload.filterBy) {
 | 
					 | 
				
			||||||
      libraryItems = await libraryHelpers.getFilteredLibraryItems(libraryItems, payload.filterBy, req.user)
 | 
					 | 
				
			||||||
      payload.total = libraryItems.length
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Determining if we are filtering titles by a series, and if so, which series
 | 
					 | 
				
			||||||
      filterSeries = (mediaIsBook && payload.filterBy.startsWith('series.')) ? libraryHelpers.decode(payload.filterBy.replace('series.', '')) : null
 | 
					 | 
				
			||||||
      if (filterSeries === 'no-series') filterSeries = null
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Step 2 - If selected, collapse library items by the series they belong to.
 | 
					 | 
				
			||||||
    // If also filtering by series, will not collapse the filtered series as this would lead
 | 
					 | 
				
			||||||
    // to series having a collapsed series that is just that series.
 | 
					 | 
				
			||||||
    if (payload.collapseseries) {
 | 
					 | 
				
			||||||
      let collapsedItems = libraryHelpers.collapseBookSeries(libraryItems, Database.series, filterSeries, req.library.settings.hideSingleBookSeries)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (!(collapsedItems.length == 1 && collapsedItems[0].collapsedSeries)) {
 | 
					 | 
				
			||||||
        libraryItems = collapsedItems
 | 
					 | 
				
			||||||
        payload.total = libraryItems.length
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Step 3 - Sort the retrieved library items.
 | 
					 | 
				
			||||||
    const sortArray = []
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // When on the series page, sort by sequence only
 | 
					 | 
				
			||||||
    if (filterSeries && !payload.sortBy) {
 | 
					 | 
				
			||||||
      sortArray.push({ asc: (li) => li.media.metadata.getSeries(filterSeries).sequence })
 | 
					 | 
				
			||||||
      // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
 | 
					 | 
				
			||||||
      sortArray.push({
 | 
					 | 
				
			||||||
        asc: (li) => {
 | 
					 | 
				
			||||||
          if (Database.serverSettings.sortingIgnorePrefix) {
 | 
					 | 
				
			||||||
            return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            return li.collapsedSeries?.name || li.media.metadata.title
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (payload.sortBy) {
 | 
					 | 
				
			||||||
      let sortKey = payload.sortBy
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Handle server setting sortingIgnorePrefix
 | 
					 | 
				
			||||||
      const sortByTitle = sortKey === 'media.metadata.title'
 | 
					 | 
				
			||||||
      if (sortByTitle && Database.serverSettings.sortingIgnorePrefix) {
 | 
					 | 
				
			||||||
        // BookMetadata.js has titleIgnorePrefix getter
 | 
					 | 
				
			||||||
        sortKey += 'IgnorePrefix'
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // If series are collapsed and not sorting by title or sequence, 
 | 
					 | 
				
			||||||
      // sort all collapsed series to the end in alphabetical order
 | 
					 | 
				
			||||||
      const sortBySequence = filterSeries && (sortKey === 'sequence')
 | 
					 | 
				
			||||||
      if (payload.collapseseries && !(sortByTitle || sortBySequence)) {
 | 
					 | 
				
			||||||
        sortArray.push({
 | 
					 | 
				
			||||||
          asc: (li) => {
 | 
					 | 
				
			||||||
            if (li.collapsedSeries) {
 | 
					 | 
				
			||||||
              return Database.serverSettings.sortingIgnorePrefix ?
 | 
					 | 
				
			||||||
                li.collapsedSeries.nameIgnorePrefix :
 | 
					 | 
				
			||||||
                li.collapsedSeries.name
 | 
					 | 
				
			||||||
            } else {
 | 
					 | 
				
			||||||
              return ''
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Sort series based on the sortBy attribute
 | 
					 | 
				
			||||||
      const direction = payload.sortDesc ? 'desc' : 'asc'
 | 
					 | 
				
			||||||
      sortArray.push({
 | 
					 | 
				
			||||||
        [direction]: (li) => {
 | 
					 | 
				
			||||||
          if (mediaIsBook && sortBySequence) {
 | 
					 | 
				
			||||||
            return li.media.metadata.getSeries(filterSeries).sequence
 | 
					 | 
				
			||||||
          } else if (mediaIsBook && sortByTitle && li.collapsedSeries) {
 | 
					 | 
				
			||||||
            return Database.serverSettings.sortingIgnorePrefix ?
 | 
					 | 
				
			||||||
              li.collapsedSeries.nameIgnorePrefix :
 | 
					 | 
				
			||||||
              li.collapsedSeries.name
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            // Supports dot notation strings i.e. "media.metadata.title"
 | 
					 | 
				
			||||||
            return sortKey.split('.').reduce((a, b) => a[b], li)
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      })
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      // Secondary sort when sorting by book author use series sort title
 | 
					 | 
				
			||||||
      if (mediaIsBook && payload.sortBy.includes('author')) {
 | 
					 | 
				
			||||||
        sortArray.push({
 | 
					 | 
				
			||||||
          asc: (li) => {
 | 
					 | 
				
			||||||
            if (li.media.metadata.series && li.media.metadata.series.length) {
 | 
					 | 
				
			||||||
              return li.media.metadata.getSeriesSortTitle(li.media.metadata.series[0])
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            return null
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (sortArray.length) {
 | 
					 | 
				
			||||||
      libraryItems = naturalSort(libraryItems).by(sortArray)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Step 3.5: Limit items
 | 
					 | 
				
			||||||
    if (payload.limit) {
 | 
					 | 
				
			||||||
      const startIndex = payload.page * payload.limit
 | 
					 | 
				
			||||||
      libraryItems = libraryItems.slice(startIndex, startIndex + payload.limit)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Step 4 - Transform the items to pass to the client side
 | 
					 | 
				
			||||||
    payload.results = await Promise.all(libraryItems.map(async li => {
 | 
					 | 
				
			||||||
      const json = payload.minified ? li.toJSONMinified() : li.toJSON()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      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
 | 
					 | 
				
			||||||
        if (filterSeries) {
 | 
					 | 
				
			||||||
          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(', ')
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        // add rssFeed object if "include=rssfeed" was put in query string (only for non-collapsed series)
 | 
					 | 
				
			||||||
        if (include.includes('rssfeed')) {
 | 
					 | 
				
			||||||
          const feedData = await this.rssFeedManager.findFeedForEntityId(json.id)
 | 
					 | 
				
			||||||
          json.rssFeed = feedData ? feedData.toJSONMinified() : null
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        // add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts)
 | 
					 | 
				
			||||||
        if (mediaIsPodcast && include.includes('numepisodesincomplete')) {
 | 
					 | 
				
			||||||
          json.numEpisodesIncomplete = req.user.getNumEpisodesIncompleteForPodcast(li)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (filterSeries) {
 | 
					 | 
				
			||||||
          // If filtering by series, make sure to include the series metadata
 | 
					 | 
				
			||||||
          json.media.metadata.series = li.media.metadata.getSeries(filterSeries)
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return json
 | 
					 | 
				
			||||||
    }))
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    res.json(payload)
 | 
					    res.json(payload)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
@ -885,7 +697,7 @@ class LibraryController {
 | 
				
			|||||||
      return res.sendStatus(403)
 | 
					      return res.sendStatus(403)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const narratorName = libraryHelpers.decode(req.params.narratorId)
 | 
					    const narratorName = libraryFilters.decode(req.params.narratorId)
 | 
				
			||||||
    const updatedName = req.body.name
 | 
					    const updatedName = req.body.name
 | 
				
			||||||
    if (!updatedName) {
 | 
					    if (!updatedName) {
 | 
				
			||||||
      return res.status(400).send('Invalid request payload. Name not specified.')
 | 
					      return res.status(400).send('Invalid request payload. Name not specified.')
 | 
				
			||||||
@ -932,7 +744,7 @@ class LibraryController {
 | 
				
			|||||||
      return res.sendStatus(403)
 | 
					      return res.sendStatus(403)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const narratorName = libraryHelpers.decode(req.params.narratorId)
 | 
					    const narratorName = libraryFilters.decode(req.params.narratorId)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Update filter data
 | 
					    // Update filter data
 | 
				
			||||||
    Database.removeNarratorFromFilterData(narratorName)
 | 
					    Database.removeNarratorFromFilterData(narratorName)
 | 
				
			||||||
@ -1030,36 +842,13 @@ class LibraryController {
 | 
				
			|||||||
    res.send(opmlText)
 | 
					    res.send(opmlText)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					 | 
				
			||||||
   * TODO: Replace with middlewareNew
 | 
					 | 
				
			||||||
   * @param {*} req 
 | 
					 | 
				
			||||||
   * @param {*} res 
 | 
					 | 
				
			||||||
   * @param {*} next 
 | 
					 | 
				
			||||||
   */
 | 
					 | 
				
			||||||
  async middleware(req, res, next) {
 | 
					 | 
				
			||||||
    if (!req.user.checkCanAccessLibrary(req.params.id)) {
 | 
					 | 
				
			||||||
      Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
 | 
					 | 
				
			||||||
      return res.sendStatus(403)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const library = await Database.libraryModel.getOldById(req.params.id)
 | 
					 | 
				
			||||||
    if (!library) {
 | 
					 | 
				
			||||||
      return res.status(404).send('Library not found')
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    req.library = library
 | 
					 | 
				
			||||||
    req.libraryItems = Database.libraryItems.filter(li => {
 | 
					 | 
				
			||||||
      return li.libraryId === library.id && req.user.checkCanAccessLibraryItem(li)
 | 
					 | 
				
			||||||
    })
 | 
					 | 
				
			||||||
    next()
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * Middleware that is not using libraryItems from memory
 | 
					   * Middleware that is not using libraryItems from memory
 | 
				
			||||||
   * @param {import('express').Request} req 
 | 
					   * @param {import('express').Request} req 
 | 
				
			||||||
   * @param {import('express').Response} res 
 | 
					   * @param {import('express').Response} res 
 | 
				
			||||||
   * @param {import('express').NextFunction} next 
 | 
					   * @param {import('express').NextFunction} next 
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  async middlewareNew(req, res, next) {
 | 
					  async middleware(req, res, next) {
 | 
				
			||||||
    if (!req.user.checkCanAccessLibrary(req.params.id)) {
 | 
					    if (!req.user.checkCanAccessLibrary(req.params.id)) {
 | 
				
			||||||
      Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
 | 
					      Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
 | 
				
			||||||
      return res.sendStatus(403)
 | 
					      return res.sendStatus(403)
 | 
				
			||||||
 | 
				
			|||||||
@ -71,30 +71,29 @@ class ApiRouter {
 | 
				
			|||||||
    //
 | 
					    //
 | 
				
			||||||
    this.router.post('/libraries', LibraryController.create.bind(this))
 | 
					    this.router.post('/libraries', LibraryController.create.bind(this))
 | 
				
			||||||
    this.router.get('/libraries', LibraryController.findAll.bind(this))
 | 
					    this.router.get('/libraries', LibraryController.findAll.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.findOne.bind(this))
 | 
					    this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this))
 | 
				
			||||||
    this.router.patch('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.update.bind(this))
 | 
					    this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this))
 | 
				
			||||||
    this.router.delete('/libraries/:id', LibraryController.middlewareNew.bind(this), LibraryController.delete.bind(this))
 | 
					    this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.router.get('/libraries/:id/items2', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryItemsNew.bind(this))
 | 
					 | 
				
			||||||
    this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
 | 
					    this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this))
 | 
				
			||||||
    this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
 | 
					    this.router.delete('/libraries/:id/issues', LibraryController.middleware.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
 | 
					    this.router.get('/libraries/:id/episode-downloads', LibraryController.middleware.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/series', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
 | 
					    this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this))
 | 
					    this.router.get('/libraries/:id/series/:seriesId', LibraryController.middleware.bind(this), LibraryController.getSeriesForLibrary.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
 | 
					    this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/playlists', LibraryController.middlewareNew.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
 | 
					    this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/personalized', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
 | 
					    this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this))
 | 
					    this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this))
 | 
					    this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this))
 | 
					    this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this))
 | 
					    this.router.get('/libraries/:id/authors', LibraryController.middleware.bind(this), LibraryController.getAuthors.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this))
 | 
					    this.router.get('/libraries/:id/narrators', LibraryController.middleware.bind(this), LibraryController.getNarrators.bind(this))
 | 
				
			||||||
    this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this))
 | 
					    this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.updateNarrator.bind(this))
 | 
				
			||||||
    this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.removeNarrator.bind(this))
 | 
					    this.router.delete('/libraries/:id/narrators/:narratorId', LibraryController.middleware.bind(this), LibraryController.removeNarrator.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/matchall', LibraryController.middlewareNew.bind(this), LibraryController.matchAll.bind(this))
 | 
					    this.router.get('/libraries/:id/matchall', LibraryController.middleware.bind(this), LibraryController.matchAll.bind(this))
 | 
				
			||||||
    this.router.post('/libraries/:id/scan', LibraryController.middlewareNew.bind(this), LibraryController.scan.bind(this))
 | 
					    this.router.post('/libraries/:id/scan', LibraryController.middleware.bind(this), LibraryController.scan.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/recent-episodes', LibraryController.middlewareNew.bind(this), LibraryController.getRecentEpisodes.bind(this))
 | 
					    this.router.get('/libraries/:id/recent-episodes', LibraryController.middleware.bind(this), LibraryController.getRecentEpisodes.bind(this))
 | 
				
			||||||
    this.router.get('/libraries/:id/opml', LibraryController.middlewareNew.bind(this), LibraryController.getOPMLFile.bind(this))
 | 
					    this.router.get('/libraries/:id/opml', LibraryController.middleware.bind(this), LibraryController.getOPMLFile.bind(this))
 | 
				
			||||||
    this.router.post('/libraries/order', LibraryController.reorder.bind(this))
 | 
					    this.router.post('/libraries/order', LibraryController.reorder.bind(this))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    //
 | 
					    //
 | 
				
			||||||
 | 
				
			|||||||
@ -6,126 +6,7 @@ const naturalSort = createNewSortInstance({
 | 
				
			|||||||
})
 | 
					})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module.exports = {
 | 
					module.exports = {
 | 
				
			||||||
  decode(text) {
 | 
					  getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
 | 
				
			||||||
    return Buffer.from(decodeURIComponent(text), 'base64').toString()
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  async getFilteredLibraryItems(libraryItems, filterBy, user) {
 | 
					 | 
				
			||||||
    let filtered = libraryItems
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'publishers', 'missing', 'languages', 'tracks', 'ebooks']
 | 
					 | 
				
			||||||
    const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
 | 
					 | 
				
			||||||
    if (group) {
 | 
					 | 
				
			||||||
      const filterVal = filterBy.replace(`${group}.`, '')
 | 
					 | 
				
			||||||
      const filter = this.decode(filterVal)
 | 
					 | 
				
			||||||
      if (group === 'genres') filtered = filtered.filter(li => 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.isBook && !li.media.metadata.series.length)
 | 
					 | 
				
			||||||
        else {
 | 
					 | 
				
			||||||
          filtered = filtered.filter(li => li.isBook && li.media.metadata.hasSeries(filter))
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      else if (group === 'authors') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasAuthor(filter))
 | 
					 | 
				
			||||||
      else if (group === 'narrators') filtered = filtered.filter(li => li.isBook && li.media.metadata.hasNarrator(filter))
 | 
					 | 
				
			||||||
      else if (group === 'publishers') filtered = filtered.filter(li => li.isBook && li.media.metadata.publisher === filter)
 | 
					 | 
				
			||||||
      else if (group === 'progress') {
 | 
					 | 
				
			||||||
        filtered = filtered.filter(li => {
 | 
					 | 
				
			||||||
          const itemProgress = user.getMediaProgress(li.id)
 | 
					 | 
				
			||||||
          if (filter === 'finished' && (itemProgress && itemProgress.isFinished)) return true
 | 
					 | 
				
			||||||
          if (filter === 'not-started' && (!itemProgress || itemProgress.notStarted)) return true
 | 
					 | 
				
			||||||
          if (filter === 'not-finished' && (!itemProgress || !itemProgress.isFinished)) return true
 | 
					 | 
				
			||||||
          if (filter === 'in-progress' && (itemProgress && itemProgress.inProgress)) return true
 | 
					 | 
				
			||||||
          return false
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      } else if (group == 'missing') {
 | 
					 | 
				
			||||||
        filtered = filtered.filter(li => {
 | 
					 | 
				
			||||||
          if (li.isBook) {
 | 
					 | 
				
			||||||
            if (filter === 'asin' && !li.media.metadata.asin) return true
 | 
					 | 
				
			||||||
            if (filter === 'isbn' && !li.media.metadata.isbn) return true
 | 
					 | 
				
			||||||
            if (filter === 'subtitle' && !li.media.metadata.subtitle) return true
 | 
					 | 
				
			||||||
            if (filter === 'authors' && !li.media.metadata.authors.length) return true
 | 
					 | 
				
			||||||
            if (filter === 'publishedYear' && !li.media.metadata.publishedYear) return true
 | 
					 | 
				
			||||||
            if (filter === 'series' && !li.media.metadata.series.length) return true
 | 
					 | 
				
			||||||
            if (filter === 'description' && !li.media.metadata.description) return true
 | 
					 | 
				
			||||||
            if (filter === 'genres' && !li.media.metadata.genres.length) return true
 | 
					 | 
				
			||||||
            if (filter === 'tags' && !li.media.tags.length) return true
 | 
					 | 
				
			||||||
            if (filter === 'narrators' && !li.media.metadata.narrators.length) return true
 | 
					 | 
				
			||||||
            if (filter === 'publisher' && !li.media.metadata.publisher) return true
 | 
					 | 
				
			||||||
            if (filter === 'language' && !li.media.metadata.language) return true
 | 
					 | 
				
			||||||
            if (filter === 'cover' && !li.media.coverPath) return true
 | 
					 | 
				
			||||||
          } else {
 | 
					 | 
				
			||||||
            return false
 | 
					 | 
				
			||||||
          }
 | 
					 | 
				
			||||||
        })
 | 
					 | 
				
			||||||
      } else if (group === 'languages') {
 | 
					 | 
				
			||||||
        filtered = filtered.filter(li => li.media.metadata.language === filter)
 | 
					 | 
				
			||||||
      } else if (group === 'tracks') {
 | 
					 | 
				
			||||||
        if (filter === 'none') filtered = filtered.filter(li => li.isBook && !li.media.numTracks)
 | 
					 | 
				
			||||||
        else if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1)
 | 
					 | 
				
			||||||
        else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1)
 | 
					 | 
				
			||||||
      } else if (group === 'ebooks') {
 | 
					 | 
				
			||||||
        if (filter === 'ebook') filtered = filtered.filter(li => li.media.ebookFile)
 | 
					 | 
				
			||||||
        else if (filter === 'supplementary') filtered = filtered.filter(li => li.libraryFiles.some(lf => lf.isEBookFile && lf.ino !== li.media.ebookFile?.ino))
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } else if (filterBy === 'issues') {
 | 
					 | 
				
			||||||
      filtered = filtered.filter(li => li.hasIssues)
 | 
					 | 
				
			||||||
    } else if (filterBy === 'feed-open') {
 | 
					 | 
				
			||||||
      const libraryItemIdsWithFeed = await Database.feedModel.findAllLibraryItemIds()
 | 
					 | 
				
			||||||
      filtered = filtered.filter(li => libraryItemIdsWithFeed.includes(li.id))
 | 
					 | 
				
			||||||
    } else if (filterBy === 'abridged') {
 | 
					 | 
				
			||||||
      filtered = filtered.filter(li => !!li.media.metadata?.abridged)
 | 
					 | 
				
			||||||
    } else if (filterBy === 'ebook') {
 | 
					 | 
				
			||||||
      filtered = filtered.filter(li => li.media.ebookFile)
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return filtered
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Returns false if should be filtered out
 | 
					 | 
				
			||||||
  checkFilterForSeriesLibraryItem(libraryItem, filterBy) {
 | 
					 | 
				
			||||||
    const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
 | 
					 | 
				
			||||||
    const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
 | 
					 | 
				
			||||||
    if (group) {
 | 
					 | 
				
			||||||
      const filterVal = filterBy.replace(`${group}.`, '')
 | 
					 | 
				
			||||||
      const filter = this.decode(filterVal)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (group === 'genres') return libraryItem.media.metadata.genres.includes(filter)
 | 
					 | 
				
			||||||
      else if (group === 'tags') return libraryItem.media.tags.includes(filter)
 | 
					 | 
				
			||||||
      else if (group === 'authors') return libraryItem.isBook && libraryItem.media.metadata.hasAuthor(filter)
 | 
					 | 
				
			||||||
      else if (group === 'narrators') return libraryItem.isBook && libraryItem.media.metadata.hasNarrator(filter)
 | 
					 | 
				
			||||||
      else if (group === 'publishers') return libraryItem.isBook && libraryItem.media.metadata.publisher === filter
 | 
					 | 
				
			||||||
      else if (group === 'languages') {
 | 
					 | 
				
			||||||
        return libraryItem.media.metadata.language === filter
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  // Return false to filter out series
 | 
					 | 
				
			||||||
  checkSeriesProgressFilter(series, filterBy, user) {
 | 
					 | 
				
			||||||
    const filter = this.decode(filterBy.split('.')[1])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    let someBookHasProgress = false
 | 
					 | 
				
			||||||
    let someBookIsUnfinished = false
 | 
					 | 
				
			||||||
    for (const libraryItem of series.books) {
 | 
					 | 
				
			||||||
      const itemProgress = user.getMediaProgress(libraryItem.id)
 | 
					 | 
				
			||||||
      if (!itemProgress || !itemProgress.isFinished) someBookIsUnfinished = true
 | 
					 | 
				
			||||||
      if (itemProgress && itemProgress.progress > 0) someBookHasProgress = true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (filter === 'finished' && (!itemProgress || !itemProgress.isFinished)) return false
 | 
					 | 
				
			||||||
      if (filter === 'not-started' && itemProgress) return false
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!someBookIsUnfinished && (filter === 'not-finished' || filter === 'in-progress')) { // Completely finished series
 | 
					 | 
				
			||||||
      return false
 | 
					 | 
				
			||||||
    } else if (!someBookHasProgress && filter === 'in-progress') { // Series not started
 | 
					 | 
				
			||||||
      return false
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
    return true
 | 
					 | 
				
			||||||
  },
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified, hideSingleBookSeries) {
 | 
					 | 
				
			||||||
    const _series = {}
 | 
					    const _series = {}
 | 
				
			||||||
    const seriesToFilterOut = {}
 | 
					    const seriesToFilterOut = {}
 | 
				
			||||||
    books.forEach((libraryItem) => {
 | 
					    books.forEach((libraryItem) => {
 | 
				
			||||||
@ -133,23 +14,10 @@ module.exports = {
 | 
				
			|||||||
      const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
 | 
					      const bookSeries = (libraryItem.media.metadata.series || []).filter(se => !seriesToFilterOut[se.id])
 | 
				
			||||||
      if (!bookSeries.length) return
 | 
					      if (!bookSeries.length) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (filterBy && user && !filterBy.startsWith('progress.')) { // Series progress filters are evaluated after grouping
 | 
					 | 
				
			||||||
        // If a single book in a series is filtered out then filter out the entire series
 | 
					 | 
				
			||||||
        if (!this.checkFilterForSeriesLibraryItem(libraryItem, filterBy)) {
 | 
					 | 
				
			||||||
          // filter out this library item
 | 
					 | 
				
			||||||
          bookSeries.forEach((bookSeriesObj) => {
 | 
					 | 
				
			||||||
            // flag series to filter it out
 | 
					 | 
				
			||||||
            seriesToFilterOut[bookSeriesObj.id] = true
 | 
					 | 
				
			||||||
            delete _series[bookSeriesObj.id]
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
          return
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      bookSeries.forEach((bookSeriesObj) => {
 | 
					      bookSeries.forEach((bookSeriesObj) => {
 | 
				
			||||||
        const series = allSeries.find(se => se.id === bookSeriesObj.id)
 | 
					        // const series = allSeries.find(se => se.id === bookSeriesObj.id)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        const abJson = minified ? libraryItem.toJSONMinified() : libraryItem.toJSONExpanded()
 | 
					        const abJson = libraryItem.toJSONMinified()
 | 
				
			||||||
        abJson.sequence = bookSeriesObj.sequence
 | 
					        abJson.sequence = bookSeriesObj.sequence
 | 
				
			||||||
        if (filterSeries) {
 | 
					        if (filterSeries) {
 | 
				
			||||||
          abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
 | 
					          abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
 | 
				
			||||||
@ -162,10 +30,8 @@ module.exports = {
 | 
				
			|||||||
            nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
 | 
					            nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
 | 
				
			||||||
            type: 'series',
 | 
					            type: 'series',
 | 
				
			||||||
            books: [abJson],
 | 
					            books: [abJson],
 | 
				
			||||||
            addedAt: series ? series.addedAt : 0,
 | 
					 | 
				
			||||||
            totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | 
					            totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
 | 
					 | 
				
			||||||
        } else {
 | 
					        } else {
 | 
				
			||||||
          _series[bookSeriesObj.id].books.push(abJson)
 | 
					          _series[bookSeriesObj.id].books.push(abJson)
 | 
				
			||||||
          _series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | 
					          _series[bookSeriesObj.id].totalDuration += isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
 | 
				
			||||||
@ -180,22 +46,17 @@ module.exports = {
 | 
				
			|||||||
      seriesItems = seriesItems.filter(se => se.books.length > 1)
 | 
					      seriesItems = seriesItems.filter(se => se.books.length > 1)
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // check progress filter
 | 
					 | 
				
			||||||
    if (filterBy && filterBy.startsWith('progress.') && user) {
 | 
					 | 
				
			||||||
      seriesItems = seriesItems.filter(se => this.checkSeriesProgressFilter(se, filterBy, user))
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return seriesItems.map((series) => {
 | 
					    return seriesItems.map((series) => {
 | 
				
			||||||
      series.books = naturalSort(series.books).asc(li => li.sequence)
 | 
					      series.books = naturalSort(series.books).asc(li => li.sequence)
 | 
				
			||||||
      return series
 | 
					      return series
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  collapseBookSeries(libraryItems, series, filterSeries, hideSingleBookSeries) {
 | 
					  collapseBookSeries(libraryItems, filterSeries, hideSingleBookSeries) {
 | 
				
			||||||
    // Get series from the library items. If this list is being collapsed after filtering for a series,
 | 
					    // 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.
 | 
					    // don't collapse that series, only books that are in other series.
 | 
				
			||||||
    const seriesObjects = this
 | 
					    const seriesObjects = this
 | 
				
			||||||
      .getSeriesFromBooks(libraryItems, series, filterSeries, null, null, true, hideSingleBookSeries)
 | 
					      .getSeriesFromBooks(libraryItems, filterSeries, hideSingleBookSeries)
 | 
				
			||||||
      .filter(s => s.id != filterSeries)
 | 
					      .filter(s => s.id != filterSeries)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const filteredLibraryItems = []
 | 
					    const filteredLibraryItems = []
 | 
				
			||||||
@ -218,5 +79,119 @@ module.exports = {
 | 
				
			|||||||
    })
 | 
					    })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return filteredLibraryItems
 | 
					    return filteredLibraryItems
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  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
 | 
				
			||||||
 | 
					      libraryItem.media = book
 | 
				
			||||||
 | 
					      return Database.libraryItemModel.getOldLibraryItem(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 sortArray = [
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        asc: (li) => li.media.metadata.getSeries(seriesId).sequence
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      { // If no series sequence then fallback to sorting by title (or collapsed series name for sub-series)
 | 
				
			||||||
 | 
					        asc: (li) => {
 | 
				
			||||||
 | 
					          if (Database.serverSettings.sortingIgnorePrefix) {
 | 
				
			||||||
 | 
					            return li.collapsedSeries?.nameIgnorePrefix || li.media.metadata.titleIgnorePrefix
 | 
				
			||||||
 | 
					          } else {
 | 
				
			||||||
 | 
					            return li.collapsedSeries?.name || li.media.metadata.title
 | 
				
			||||||
 | 
					          }
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    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.metadata.getSeries(seriesId)
 | 
				
			||||||
 | 
					      const json = li.toJSONMinified()
 | 
				
			||||||
 | 
					      json.media.metadata.series = {
 | 
				
			||||||
 | 
					        id: filteredSeries.id,
 | 
				
			||||||
 | 
					        sequence: filteredSeries.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
 | 
				
			||||||
 | 
					    }))
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user