Update new library item API endpoint to handle collapse series

This commit is contained in:
advplyr 2023-07-30 17:51:44 -05:00
parent 11120a3765
commit eeaf012cdc
6 changed files with 232 additions and 16 deletions

View File

@ -313,7 +313,12 @@ export default {
this.currentSFQueryString = this.buildSearchParams()
}
const 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 podcasts and collapse sub-series
if (entityPath === 'items' && !this.isPodcast && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) {
entityPath += '2'
}
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`

View File

@ -208,7 +208,7 @@ class LibraryController {
payload.offset = payload.page * payload.limit
const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library.id, req.user.id, payload)
payload.results = libraryItems.map(li => li.toJSONMinified())
payload.results = libraryItems
payload.total = count
res.json(payload)

View File

@ -17,7 +17,7 @@ module.exports = (sequelize) => {
}
static getOldFeed(feedExpanded) {
const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode())
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
return new oldFeed({
id: feedExpanded.id,
slug: feedExpanded.slug,
@ -42,7 +42,7 @@ module.exports = (sequelize) => {
},
serverAddress: feedExpanded.serverAddress,
feedUrl: feedExpanded.feedURL,
episodes,
episodes: episodes || [],
createdAt: feedExpanded.createdAt.valueOf(),
updatedAt: feedExpanded.updatedAt.valueOf()
})

View File

@ -400,10 +400,22 @@ module.exports = (sequelize) => {
})
}
static async getByFilterAndSort(libraryId, userId, { filterBy, sortBy, sortDesc, limit, offset }) {
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, filterBy, sortBy, sortDesc, limit, offset, userId)
static async getByFilterAndSort(libraryId, userId, options) {
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, userId, options)
return {
libraryItems: libraryItems.map(ti => this.getOldLibraryItem(ti)),
libraryItems: libraryItems.map(li => {
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
if (li.collapsedSeries) {
oldLibraryItem.collapsedSeries = li.collapsedSeries
}
if (li.series) {
oldLibraryItem.media.metadata.series = li.series
}
if (li.rssFeed) {
oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
}
return oldLibraryItem
}),
count
}
}

View File

@ -5,7 +5,9 @@ module.exports = {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
async getFilteredLibraryItems(libraryId, filterBy, sortBy, sortDesc, limit, offset, userId) {
async getFilteredLibraryItems(libraryId, userId, options) {
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include } = options
let filterValue = null
let filterGroup = null
if (filterBy) {
@ -16,6 +18,6 @@ module.exports = {
}
// TODO: Handle podcast filters
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, limit, offset)
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)
}
}

View File

@ -3,6 +3,51 @@ const Database = require('../../Database')
const Logger = require('../../Logger')
module.exports = {
getCollapseSeriesMediaProgressFilter(value) {
const mediaWhere = {}
if (value === 'not-finished') {
mediaWhere['$books.mediaProgresses.isFinished$'] = {
[Sequelize.Op.or]: [null, false]
}
} else if (value === 'not-started') {
mediaWhere[Sequelize.Op.and] = [
{
'$books.mediaProgresses.currentTime$': {
[Sequelize.Op.or]: [null, 0]
}
},
{
'$books.mediaProgresses.isFinished$': {
[Sequelize.Op.or]: [null, false]
}
}
]
} else if (value === 'finished') {
mediaWhere['$books.mediaProgresses.isFinished$'] = true
} else if (value === 'in-progress') {
mediaWhere[Sequelize.Op.and] = [
{
[Sequelize.Op.or]: [
{
'$books.mediaProgresses.currentTime$': {
[Sequelize.Op.gt]: 0
}
},
{
'$books.mediaProgresses.ebookProgress$': {
[Sequelize.Op.gt]: 0
}
}
]
},
{
'$books.mediaProgresses.isFinished$': false
}
]
}
return mediaWhere
},
/**
* Get where options for Book model
* @param {string} group
@ -104,9 +149,10 @@ module.exports = {
* Get sequelize order
* @param {string} sortBy
* @param {boolean} sortDesc
* @param {boolean} collapseseries
* @returns {Sequelize.order}
*/
getOrder(sortBy, sortDesc) {
getOrder(sortBy, sortDesc, collapseseries) {
const dir = sortDesc ? 'DESC' : 'ASC'
if (sortBy === 'addedAt') {
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
@ -125,15 +171,69 @@ module.exports = {
} else if (sortBy === 'media.metadata.authorName') {
return [['author_name', dir]]
} else if (sortBy === 'media.metadata.title') {
if (collapseseries) {
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
}
if (global.ServerSettings.sortingIgnorePrefix) {
return [['titleIgnorePrefix', dir]]
} else {
return [['title', dir]]
}
} else if (sortBy === 'sequence') {
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
return [[Sequelize.literal(`\`series.bookSeries.sequence\` COLLATE NOCASE ${nullDir}`)]]
}
return []
},
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
const allSeries = await Database.models.series.findAll({
attributes: [
'id',
'name',
[Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks']
],
distinct: true,
subQuery: false,
where: seriesWhere,
include: [
{
model: Database.models.book,
attributes: ['id', 'title'],
through: {
attributes: ['id', 'seriesId', 'bookId', 'sequence']
},
...bookFindOptions,
required: true
}
],
order: [
Sequelize.literal('`books.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST')
]
})
const bookSeriesToInclude = []
const booksToInclude = []
let booksToExclude = []
allSeries.forEach(s => {
let found = false
for (let book of s.books) {
if (!found && !booksToInclude.includes(book.id)) {
booksToInclude.push(book.id)
bookSeriesToInclude.push({
id: book.bookSeries.id,
numBooks: s.dataValues.numBooks
})
booksToExclude = booksToExclude.filter(bid => bid !== book.id)
found = true
} else if (!booksToExclude.includes(book.id) && !booksToInclude.includes(book.id)) {
booksToExclude.push(book.id)
}
}
})
return { booksToExclude, bookSeriesToInclude }
},
/**
* Get library items for book media type using filter and sort
* @param {string} libraryId
@ -141,11 +241,22 @@ module.exports = {
* @param {[string]} filterValue
* @param {string} sortBy
* @param {string} sortDesc
* @param {boolean} collapseseries
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, limit, offset) {
async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) {
// TODO: Handle collapse sub-series
if (filterGroup === 'series' && collapseseries) {
collapseseries = false
}
if (filterGroup !== 'series' && sortBy === 'sequence') {
sortBy = 'media.metadata.title'
}
const includeRSSFeed = include.includes('rssfeed')
// For sorting by author name an additional attribute must be added
// with author names concatenated
let bookAttributes = null
@ -169,10 +280,10 @@ module.exports = {
let seriesInclude = {
model: Database.models.bookSeries,
attributes: ['seriesId', 'sequence', 'createdAt'],
attributes: ['id', 'seriesId', 'sequence', 'createdAt'],
include: {
model: Database.models.series,
attributes: ['id', 'name']
attributes: ['id', 'name', 'nameIgnorePrefix']
},
order: [
['createdAt', 'ASC']
@ -193,9 +304,17 @@ module.exports = {
separate: true
}
const sortOrder = this.getOrder(sortBy, sortDesc, collapseseries)
const libraryItemIncludes = []
const bookIncludes = []
if (filterGroup === 'feed-open') {
if (includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
required: filterGroup === 'feed-open'
})
}
if (filterGroup === 'feed-open' && !includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
required: true
@ -243,6 +362,10 @@ module.exports = {
attributes: ['sequence']
}
})
if (sortBy !== 'sequence') {
// Secondary sort by sequence
sortOrder.push([Sequelize.literal('`series.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST')])
}
} else if (filterGroup === 'issues') {
libraryItemWhere[Sequelize.Op.or] = [
{
@ -263,8 +386,52 @@ module.exports = {
})
}
const bookWhere = filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : {}
let collapseSeriesBookSeries = []
if (collapseseries) {
let seriesBookWhere = null
let seriesWhere = null
if (filterGroup === 'progress') {
seriesWhere = this.getCollapseSeriesMediaProgressFilter(filterValue)
} else if (filterGroup === 'missing' && filterValue === 'authors') {
seriesWhere = {
['$books.authors.id$']: null
}
} else {
seriesBookWhere = bookWhere
}
const bookFindOptions = {
where: seriesBookWhere,
include: [
{
model: Database.models.libraryItem,
required: true,
where: libraryItemWhere,
include: libraryItemIncludes
},
authorInclude,
...bookIncludes
]
}
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)
if (booksToExclude.length) {
bookWhere['id'] = {
[Sequelize.Op.notIn]: booksToExclude
}
}
collapseSeriesBookSeries = bookSeriesToInclude
if (!bookAttributes?.include) bookAttributes = { include: [] }
if (global.ServerSettings.sortingIgnorePrefix) {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.nameIgnorePrefix FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), titleIgnorePrefix)`), 'display_title'])
} else {
bookAttributes.include.push([Sequelize.literal(`IFNULL((SELECT s.name FROM bookSeries AS bs, series AS s WHERE bs.seriesId = s.id AND bs.bookId = book.id AND bs.id IN (${bookSeriesToInclude.map(v => `"${v.id}"`).join(', ')})), title)`), 'display_title'])
}
}
const { rows: books, count } = await Database.models.book.findAndCountAll({
where: filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : null,
where: bookWhere,
distinct: true,
attributes: bookAttributes,
include: [
@ -278,7 +445,7 @@ module.exports = {
authorInclude,
...bookIncludes
],
order: this.getOrder(sortBy, sortDesc),
order: sortOrder,
subQuery: false,
limit,
offset
@ -287,9 +454,39 @@ module.exports = {
const libraryItems = books.map((bookExpanded) => {
const libraryItem = bookExpanded.libraryItem.toJSON()
const book = bookExpanded.toJSON()
if (filterGroup === 'series' && book.series?.length) {
// For showing sequence on book cover when filtering for series
libraryItem.series = {
id: book.series[0].id,
name: book.series[0].name,
sequence: book.series[0].bookSeries?.sequence || null
}
}
delete book.libraryItem
delete book.authors
delete book.series
// For showing details of collapsed series
if (collapseseries && book.bookSeries?.length) {
const collapsedSeries = book.bookSeries.find(bs => collapseSeriesBookSeries.some(cbs => cbs.id === bs.id))
if (collapsedSeries) {
const collapseSeriesObj = collapseSeriesBookSeries.find(csbs => csbs.id === collapsedSeries.id)
libraryItem.collapsedSeries = {
id: collapsedSeries.series.id,
name: collapsedSeries.series.name,
nameIgnorePrefix: collapsedSeries.series.nameIgnorePrefix,
sequence: collapsedSeries.sequence,
numBooks: collapseSeriesObj?.numBooks || 0
}
}
}
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
libraryItem.media = book
return libraryItem