240 lines
8.4 KiB
JavaScript
240 lines
8.4 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 = {
|
|
getSeriesFromBooks(books, filterSeries, hideSingleBookSeries) {
|
|
const _series = {}
|
|
const seriesToFilterOut = {}
|
|
books.forEach((libraryItem) => {
|
|
// get all book series for item that is not already filtered out
|
|
const bookSeries = (libraryItem.media.metadata.series || []).filter((se) => !seriesToFilterOut[se.id])
|
|
if (!bookSeries.length) return
|
|
|
|
bookSeries.forEach((bookSeriesObj) => {
|
|
// const series = allSeries.find(se => se.id === bookSeriesObj.id)
|
|
|
|
const abJson = libraryItem.toJSONMinified()
|
|
abJson.sequence = bookSeriesObj.sequence
|
|
if (filterSeries) {
|
|
abJson.filterSeriesSequence = libraryItem.media.metadata.getSeries(filterSeries).sequence
|
|
}
|
|
if (!_series[bookSeriesObj.id]) {
|
|
_series[bookSeriesObj.id] = {
|
|
id: bookSeriesObj.id,
|
|
name: bookSeriesObj.name,
|
|
nameIgnorePrefix: getTitlePrefixAtEnd(bookSeriesObj.name),
|
|
nameIgnorePrefixSort: getTitleIgnorePrefix(bookSeriesObj.name),
|
|
type: 'series',
|
|
books: [abJson],
|
|
totalDuration: isNullOrNaN(abJson.media.duration) ? 0 : Number(abJson.media.duration)
|
|
}
|
|
} else {
|
|
_series[bookSeriesObj.id].books.push(abJson)
|
|
_series[bookSeriesObj.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
|
|
})
|
|
},
|
|
|
|
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
|
|
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 sortingIgnorePrefix = Database.serverSettings.sortingIgnorePrefix
|
|
|
|
let sortArray = []
|
|
const direction = payload.sortDesc ? 'desc' : 'asc'
|
|
if (!payload.sortBy || payload.sortBy === 'sequence') {
|
|
sortArray = [
|
|
{
|
|
[direction]: (li) => li.media.metadata.getSeries(seriesId).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.metadata.titleIgnorePrefix
|
|
} else {
|
|
return li.collapsedSeries?.name || li.media.metadata.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.metadata.titleIgnorePrefix
|
|
} else {
|
|
return li.collapsedSeries?.name || li.media.metadata.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.metadata.getSeries(seriesId)
|
|
const json = li.toJSONMinified()
|
|
json.media.metadata.series = {
|
|
id: filteredSeries.id,
|
|
name: filteredSeries.name,
|
|
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
|
|
})
|
|
)
|
|
}
|
|
}
|