mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-03-14 00:21:31 +01:00
Update new library item API endpoint to handle collapse series
This commit is contained in:
parent
11120a3765
commit
eeaf012cdc
@ -313,7 +313,12 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
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 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`
|
||||||
|
|
||||||
|
@ -208,7 +208,7 @@ class LibraryController {
|
|||||||
payload.offset = payload.page * payload.limit
|
payload.offset = payload.page * payload.limit
|
||||||
|
|
||||||
const { libraryItems, count } = await Database.models.libraryItem.getByFilterAndSort(req.library.id, req.user.id, payload)
|
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
|
payload.total = count
|
||||||
|
|
||||||
res.json(payload)
|
res.json(payload)
|
||||||
|
@ -17,7 +17,7 @@ module.exports = (sequelize) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getOldFeed(feedExpanded) {
|
static getOldFeed(feedExpanded) {
|
||||||
const episodes = feedExpanded.feedEpisodes.map((feedEpisode) => feedEpisode.getOldEpisode())
|
const episodes = feedExpanded.feedEpisodes?.map((feedEpisode) => feedEpisode.getOldEpisode())
|
||||||
return new oldFeed({
|
return new oldFeed({
|
||||||
id: feedExpanded.id,
|
id: feedExpanded.id,
|
||||||
slug: feedExpanded.slug,
|
slug: feedExpanded.slug,
|
||||||
@ -42,7 +42,7 @@ module.exports = (sequelize) => {
|
|||||||
},
|
},
|
||||||
serverAddress: feedExpanded.serverAddress,
|
serverAddress: feedExpanded.serverAddress,
|
||||||
feedUrl: feedExpanded.feedURL,
|
feedUrl: feedExpanded.feedURL,
|
||||||
episodes,
|
episodes: episodes || [],
|
||||||
createdAt: feedExpanded.createdAt.valueOf(),
|
createdAt: feedExpanded.createdAt.valueOf(),
|
||||||
updatedAt: feedExpanded.updatedAt.valueOf()
|
updatedAt: feedExpanded.updatedAt.valueOf()
|
||||||
})
|
})
|
||||||
|
@ -400,10 +400,22 @@ module.exports = (sequelize) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getByFilterAndSort(libraryId, userId, { filterBy, sortBy, sortDesc, limit, offset }) {
|
static async getByFilterAndSort(libraryId, userId, options) {
|
||||||
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, filterBy, sortBy, sortDesc, limit, offset, userId)
|
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, userId, options)
|
||||||
return {
|
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
|
count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -5,7 +5,9 @@ module.exports = {
|
|||||||
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
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 filterValue = null
|
||||||
let filterGroup = null
|
let filterGroup = null
|
||||||
if (filterBy) {
|
if (filterBy) {
|
||||||
@ -16,6 +18,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Handle podcast filters
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -3,6 +3,51 @@ const Database = require('../../Database')
|
|||||||
const Logger = require('../../Logger')
|
const Logger = require('../../Logger')
|
||||||
|
|
||||||
module.exports = {
|
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
|
* Get where options for Book model
|
||||||
* @param {string} group
|
* @param {string} group
|
||||||
@ -104,9 +149,10 @@ module.exports = {
|
|||||||
* Get sequelize order
|
* Get sequelize order
|
||||||
* @param {string} sortBy
|
* @param {string} sortBy
|
||||||
* @param {boolean} sortDesc
|
* @param {boolean} sortDesc
|
||||||
|
* @param {boolean} collapseseries
|
||||||
* @returns {Sequelize.order}
|
* @returns {Sequelize.order}
|
||||||
*/
|
*/
|
||||||
getOrder(sortBy, sortDesc) {
|
getOrder(sortBy, sortDesc, collapseseries) {
|
||||||
const dir = sortDesc ? 'DESC' : 'ASC'
|
const dir = sortDesc ? 'DESC' : 'ASC'
|
||||||
if (sortBy === 'addedAt') {
|
if (sortBy === 'addedAt') {
|
||||||
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
|
||||||
@ -125,15 +171,69 @@ module.exports = {
|
|||||||
} else if (sortBy === 'media.metadata.authorName') {
|
} else if (sortBy === 'media.metadata.authorName') {
|
||||||
return [['author_name', dir]]
|
return [['author_name', dir]]
|
||||||
} else if (sortBy === 'media.metadata.title') {
|
} else if (sortBy === 'media.metadata.title') {
|
||||||
|
if (collapseseries) {
|
||||||
|
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
|
||||||
|
}
|
||||||
|
|
||||||
if (global.ServerSettings.sortingIgnorePrefix) {
|
if (global.ServerSettings.sortingIgnorePrefix) {
|
||||||
return [['titleIgnorePrefix', dir]]
|
return [['titleIgnorePrefix', dir]]
|
||||||
} else {
|
} else {
|
||||||
return [['title', dir]]
|
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 []
|
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
|
* Get library items for book media type using filter and sort
|
||||||
* @param {string} libraryId
|
* @param {string} libraryId
|
||||||
@ -141,11 +241,22 @@ module.exports = {
|
|||||||
* @param {[string]} filterValue
|
* @param {[string]} filterValue
|
||||||
* @param {string} sortBy
|
* @param {string} sortBy
|
||||||
* @param {string} sortDesc
|
* @param {string} sortDesc
|
||||||
|
* @param {boolean} collapseseries
|
||||||
|
* @param {string[]} include
|
||||||
* @param {number} limit
|
* @param {number} limit
|
||||||
* @param {number} offset
|
* @param {number} offset
|
||||||
* @returns {object} { libraryItems:LibraryItem[], count:number }
|
* @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
|
// For sorting by author name an additional attribute must be added
|
||||||
// with author names concatenated
|
// with author names concatenated
|
||||||
let bookAttributes = null
|
let bookAttributes = null
|
||||||
@ -169,10 +280,10 @@ module.exports = {
|
|||||||
|
|
||||||
let seriesInclude = {
|
let seriesInclude = {
|
||||||
model: Database.models.bookSeries,
|
model: Database.models.bookSeries,
|
||||||
attributes: ['seriesId', 'sequence', 'createdAt'],
|
attributes: ['id', 'seriesId', 'sequence', 'createdAt'],
|
||||||
include: {
|
include: {
|
||||||
model: Database.models.series,
|
model: Database.models.series,
|
||||||
attributes: ['id', 'name']
|
attributes: ['id', 'name', 'nameIgnorePrefix']
|
||||||
},
|
},
|
||||||
order: [
|
order: [
|
||||||
['createdAt', 'ASC']
|
['createdAt', 'ASC']
|
||||||
@ -193,9 +304,17 @@ module.exports = {
|
|||||||
separate: true
|
separate: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortOrder = this.getOrder(sortBy, sortDesc, collapseseries)
|
||||||
|
|
||||||
const libraryItemIncludes = []
|
const libraryItemIncludes = []
|
||||||
const bookIncludes = []
|
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({
|
libraryItemIncludes.push({
|
||||||
model: Database.models.feed,
|
model: Database.models.feed,
|
||||||
required: true
|
required: true
|
||||||
@ -243,6 +362,10 @@ module.exports = {
|
|||||||
attributes: ['sequence']
|
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') {
|
} else if (filterGroup === 'issues') {
|
||||||
libraryItemWhere[Sequelize.Op.or] = [
|
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({
|
const { rows: books, count } = await Database.models.book.findAndCountAll({
|
||||||
where: filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : null,
|
where: bookWhere,
|
||||||
distinct: true,
|
distinct: true,
|
||||||
attributes: bookAttributes,
|
attributes: bookAttributes,
|
||||||
include: [
|
include: [
|
||||||
@ -278,7 +445,7 @@ module.exports = {
|
|||||||
authorInclude,
|
authorInclude,
|
||||||
...bookIncludes
|
...bookIncludes
|
||||||
],
|
],
|
||||||
order: this.getOrder(sortBy, sortDesc),
|
order: sortOrder,
|
||||||
subQuery: false,
|
subQuery: false,
|
||||||
limit,
|
limit,
|
||||||
offset
|
offset
|
||||||
@ -287,9 +454,39 @@ module.exports = {
|
|||||||
const libraryItems = books.map((bookExpanded) => {
|
const libraryItems = books.map((bookExpanded) => {
|
||||||
const libraryItem = bookExpanded.libraryItem.toJSON()
|
const libraryItem = bookExpanded.libraryItem.toJSON()
|
||||||
const book = bookExpanded.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.libraryItem
|
||||||
delete book.authors
|
delete book.authors
|
||||||
delete book.series
|
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
|
libraryItem.media = book
|
||||||
|
|
||||||
return libraryItem
|
return libraryItem
|
||||||
|
Loading…
Reference in New Issue
Block a user