Update get library series api endpoint to load from db

This commit is contained in:
advplyr 2023-08-18 17:08:34 -05:00
parent 9d7d4c6902
commit 4e4a976050
8 changed files with 276 additions and 13 deletions

View File

@ -317,6 +317,8 @@ export default {
// 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'
} else if (entityPath === 'series') {
entityPath += '2'
}
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
@ -628,6 +630,11 @@ export default {
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.entityName === 'series') {
this.booksPerFetch = 50
} else {
this.booksPerFetch = 100
}
this.checkUpdateSearchParams()
this.initSizeData(bookshelf)

View File

@ -8,6 +8,7 @@ const Library = require('../objects/Library')
const libraryHelpers = require('../utils/libraryHelpers')
const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
const libraryItemFilters = require('../utils/queries/libraryItemFilters')
const seriesFilters = require('../utils/queries/seriesFilters')
const { sort, createNewSortInstance } = require('../libs/fastSort')
const naturalSort = createNewSortInstance({
comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
@ -519,12 +520,42 @@ class LibraryController {
res.sendStatus(200)
}
/**
* GET: /api/libraries/:id/series2
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAllSeriesForLibraryNew(req, res) {
const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v)
const payload = {
results: [],
total: 0,
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,
minified: req.query.minified === '1',
include: include.join(',')
}
const offset = payload.page * payload.limit
const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)
payload.total = count
payload.results = series
res.json(payload)
}
/**
* GET: /api/libraries/:id/series
* Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
*
* @param {*} req
* @param {*} res
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAllSeriesForLibrary(req, res) {
const libraryItems = req.libraryItems

View File

@ -100,7 +100,24 @@ class Series extends Model {
description: DataTypes.TEXT
}, {
sequelize,
modelName: 'series'
modelName: 'series',
indexes: [
{
fields: [{
name: 'name',
collate: 'NOCASE'
}]
},
{
fields: [{
name: 'nameIgnorePrefix',
collate: 'NOCASE'
}]
},
{
fields: ['libraryId']
}
]
})
const { library } = sequelize.models

View File

@ -1,5 +1,5 @@
const uuidv4 = require("uuid").v4
const { getTitleIgnorePrefix } = require('../../utils/index')
const { getTitleIgnorePrefix, getTitlePrefixAtEnd } = require('../../utils/index')
class Series {
constructor(series) {
@ -33,6 +33,7 @@ class Series {
return {
id: this.id,
name: this.name,
nameIgnorePrefix: getTitlePrefixAtEnd(this.name),
description: this.description,
addedAt: this.addedAt,
updatedAt: this.updatedAt,

View File

@ -78,6 +78,7 @@ class ApiRouter {
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.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this))
this.router.get('/libraries/:id/series2', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibraryNew.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/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this))

View File

@ -41,11 +41,11 @@ module.exports = {
/**
* Get library items for continue listening & continue reading shelves
* @param {oldLibrary} library
* @param {oldUser} user
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { items:LibraryItem[], count:number }
* @returns {Promise<{ items:import('../../models/LibraryItem')[], count:number }>}
*/
async getMediaItemsInProgress(library, user, include, limit) {
if (library.mediaType === 'book') {
@ -176,14 +176,14 @@ module.exports = {
/**
* Get series for recent series shelf
* @param {oldLibrary} library
* @param {oldUser} user
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string[]} include
* @param {number} limit
* @returns {object} { series:oldSeries[], count:number}
* @returns {{ series:import('../../objects/entities/Series')[], count:number}}
*/
async getSeriesMostRecentlyAdded(library, user, include, limit) {
if (library.mediaType !== 'book') return { series: [], count: 0 }
if (!library.isBook) return { series: [], count: 0 }
const seriesIncludes = []
if (include.includes('rssfeed')) {
@ -390,7 +390,7 @@ module.exports = {
/**
* Get filter data used in filter menus
* @param {oldLibrary} oldLibrary
* @param {import('../../objects/Library')} oldLibrary
* @returns {Promise<object>}
*/
async getFilterData(oldLibrary) {

View File

@ -6,7 +6,7 @@ module.exports = {
/**
* User permissions to restrict books for explicit content & tags
* @param {oldUser} user
* @returns {object} { bookWhere:Sequelize.WhereOptions, replacements:string[] }
* @returns {{ bookWhere:Sequelize.WhereOptions, replacements:object }}
*/
getUserPermissionBookWhereQuery(user) {
const bookWhere = []

View File

@ -0,0 +1,206 @@
const Sequelize = require('sequelize')
const Logger = require('../../Logger')
const Database = require('../../Database')
const libraryItemsBookFilters = require('./libraryItemsBookFilters')
module.exports = {
decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString()
},
/**
* Get series filtered and sorted
*
* @param {import('../../objects/Library')} library
* @param {import('../../objects/user/User')} user
* @param {string} filterBy
* @param {string} sortBy
* @param {boolean} sortDesc
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {Promise<{ series:object[], count:number }>}
*/
async getFilteredSeries(library, user, filterBy, sortBy, sortDesc, include, limit, offset) {
let filterValue = null
let filterGroup = null
if (filterBy) {
const searchGroups = ['genres', 'tags', 'authors', 'progress', 'narrators', 'publishers', 'languages']
const group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
filterGroup = group || filterBy
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
}
const seriesIncludes = []
if (include.includes('rssfeed')) {
seriesIncludes.push({
model: Database.models.feed
})
}
const userPermissionBookWhere = libraryItemsBookFilters.getUserPermissionBookWhereQuery(user)
const seriesWhere = [
{
libraryId: library.id
}
]
// Handle library setting to hide single book series
// TODO: Merge with existing query
if (library.settings.hideSingleBookSeries) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id)`), {
[Sequelize.Op.gt]: 1
}))
}
// Handle filters
// TODO: Simplify and break-out
let attrQuery = null
if (['genres', 'tags', 'narrators'].includes(filterGroup)) {
attrQuery = `SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (SELECT count(*) FROM json_each(b.${filterGroup}) WHERE json_valid(b.${filterGroup}) AND json_each.value = :filterValue) > 0`
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'authors') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs, bookAuthors ba WHERE bs.seriesId = series.id AND bs.bookId = b.id AND ba.bookId = b.id AND ba.authorId = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'publishers') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.publisher = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'languages') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id AND b.language = :filterValue'
userPermissionBookWhere.replacements.filterValue = filterValue
} else if (filterGroup === 'progress') {
if (filterValue === 'not-finished') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
} else if (filterValue === 'finished') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished IS NULL OR mp.isFinished = 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
} else if (filterValue === 'not-started') {
const progQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.isFinished = 1 OR mp.currentTime > 0)'
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${progQuery})`), 0))
} else if (filterValue === 'in-progress') {
attrQuery = 'SELECT count(*) FROM books b, bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = b.id WHERE bs.seriesId = series.id AND bs.bookId = b.id AND (mp.currentTime > 0 OR mp.ebookProgress > 0) AND mp.isFinished = 0'
}
}
// Handle user permissions to only include series with at least 1 book
// TODO: Simplify to a single query
if (userPermissionBookWhere.bookWhere.length) {
if (!attrQuery) attrQuery = 'SELECT count(*) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND bs.bookId = b.id'
if (!user.canAccessExplicitContent) {
attrQuery += ' AND b.explicit = 0'
}
if (!user.permissions.accessAllTags && user.itemTagsSelected.length) {
if (user.permissions.selectedTagsNotAccessible) {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) = 0'
} else {
attrQuery += ' AND (SELECT count(*) FROM json_each(tags) WHERE json_valid(tags) AND json_each.value IN (:userTagsSelected)) > 0'
}
}
}
if (attrQuery) {
seriesWhere.push(Sequelize.where(Sequelize.literal(`(${attrQuery})`), {
[Sequelize.Op.gt]: 0
}))
}
const order = []
let seriesAttributes = {
include: []
}
// Handle sort order
const dir = sortDesc ? 'DESC' : 'ASC'
if (sortBy === 'numBooks') {
seriesAttributes.include.push([Sequelize.literal('(SELECT count(*) FROM bookSeries bs WHERE bs.seriesId = series.id)'), 'numBooks'])
order.push(['numBooks', dir])
} else if (sortBy === 'addedAt') {
order.push(['createdAt', dir])
} else if (sortBy === 'name') {
if (global.ServerSettings.sortingIgnorePrefix) {
order.push([Sequelize.literal('nameIgnorePrefix COLLATE NOCASE'), dir])
} else {
order.push([Sequelize.literal('`series`.`name` COLLATE NOCASE'), dir])
}
} else if (sortBy === 'totalDuration') {
seriesAttributes.include.push([Sequelize.literal('(SELECT SUM(b.duration) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'totalDuration'])
order.push(['totalDuration', dir])
} else if (sortBy === 'lastBookAdded') {
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.createdAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookAdded'])
order.push(['mostRecentBookAdded', dir])
} else if (sortBy === 'lastBookUpdated') {
seriesAttributes.include.push([Sequelize.literal('(SELECT MAX(b.updatedAt) FROM books b, bookSeries bs WHERE bs.seriesId = series.id AND b.id = bs.bookId)'), 'mostRecentBookUpdated'])
order.push(['mostRecentBookUpdated', dir])
}
const { rows: series, count } = await Database.seriesModel.findAndCountAll({
where: seriesWhere,
limit,
offset,
distinct: true,
subQuery: false,
benchmark: true,
logging: (sql, timeMs) => {
console.log(`[Query] Series filter/sort. Elapsed ${timeMs}ms`)
},
attributes: seriesAttributes,
replacements: userPermissionBookWhere.replacements,
include: [
{
model: Database.models.bookSeries,
include: {
model: Database.models.book,
where: userPermissionBookWhere.bookWhere,
include: [
{
model: Database.models.libraryItem
}
]
},
separate: true
},
...seriesIncludes
],
order
})
// Map series to old series
const allOldSeries = []
for (const s of series) {
const oldSeries = s.getOldSeries().toJSON()
if (s.dataValues.totalDuration) {
oldSeries.totalDuration = s.dataValues.totalDuration
}
if (s.feeds?.length) {
oldSeries.rssFeed = Database.models.feed.getOldFeed(s.feeds[0]).toJSONMinified()
}
// TODO: Sort books by sequence in query
s.bookSeries.sort((a, b) => {
if (!a.sequence) return 1
if (!b.sequence) return -1
return a.sequence.localeCompare(b.sequence, undefined, {
numeric: true,
sensitivity: 'base'
})
})
oldSeries.books = s.bookSeries.map(bs => {
const libraryItem = bs.book.libraryItem.toJSON()
delete bs.book.libraryItem
libraryItem.media = bs.book
const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(libraryItem).toJSONMinified()
return oldLibraryItem
})
allOldSeries.push(oldSeries)
}
return {
series: allOldSeries,
count
}
}
}