Include library item podcast queries

This commit is contained in:
advplyr 2023-07-31 17:59:51 -05:00
parent eeaf012cdc
commit 95c4b3862b
7 changed files with 231 additions and 22 deletions

View File

@ -314,8 +314,8 @@ 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 podcasts and collapse sub-series // TODO: Temp use new library items API for everything except collapse sub-series
if (entityPath === 'items' && !this.isPodcast && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) { if (entityPath === 'items' && !this.collapseBookSeries && !(this.filterName === 'Series' && this.collapseSeries)) {
entityPath += '2' entityPath += '2'
} }

View File

@ -207,7 +207,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, req.user.id, payload)
payload.results = libraryItems payload.results = libraryItems
payload.total = count payload.total = count

View File

@ -400,8 +400,15 @@ module.exports = (sequelize) => {
}) })
} }
static async getByFilterAndSort(libraryId, userId, options) { /**
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(libraryId, userId, options) * Get library items using filter and sort
* @param {oldLibrary} library
* @param {string} userId
* @param {object} options
* @returns {object} { libraryItems:oldLibraryItem[], count:number }
*/
static async getByFilterAndSort(library, userId, options) {
const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library, userId, options)
return { return {
libraryItems: libraryItems.map(li => { libraryItems: libraryItems.map(li => {
const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
@ -414,6 +421,16 @@ module.exports = (sequelize) => {
if (li.rssFeed) { if (li.rssFeed) {
oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified() oldLibraryItem.rssFeed = sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
} }
if (li.media.numEpisodes) {
oldLibraryItem.media.numEpisodes = li.media.numEpisodes
}
if (li.size && !oldLibraryItem.media.size) {
oldLibraryItem.media.size = li.size
}
if (li.numEpisodesIncomplete) {
oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
}
return oldLibraryItem return oldLibraryItem
}), }),
count count

View File

@ -4,7 +4,7 @@ module.exports = (sequelize) => {
class Podcast extends Model { class Podcast extends Model {
static getOldPodcast(libraryItemExpanded) { static getOldPodcast(libraryItemExpanded) {
const podcastExpanded = libraryItemExpanded.media const podcastExpanded = libraryItemExpanded.media
const podcastEpisodes = podcastExpanded.podcastEpisodes.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index) const podcastEpisodes = podcastExpanded.podcastEpisodes?.map(ep => ep.getOldPodcastEpisode(libraryItemExpanded.id)).sort((a, b) => a.index - b.index)
return { return {
id: podcastExpanded.id, id: podcastExpanded.id,
libraryItemId: libraryItemExpanded.id, libraryItemId: libraryItemExpanded.id,
@ -25,7 +25,7 @@ module.exports = (sequelize) => {
}, },
coverPath: podcastExpanded.coverPath, coverPath: podcastExpanded.coverPath,
tags: podcastExpanded.tags, tags: podcastExpanded.tags,
episodes: podcastEpisodes, episodes: podcastEpisodes || [],
autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes,
autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, autoDownloadSchedule: podcastExpanded.autoDownloadSchedule,
lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null,

View File

@ -1,12 +1,20 @@
const libraryItemsBookFilters = require('./libraryItemsBookFilters') const libraryItemsBookFilters = require('./libraryItemsBookFilters')
const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters')
module.exports = { module.exports = {
decode(text) { decode(text) {
return Buffer.from(decodeURIComponent(text), 'base64').toString() return Buffer.from(decodeURIComponent(text), 'base64').toString()
}, },
async getFilteredLibraryItems(libraryId, userId, options) { /**
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include } = options * Get library items using filter and sort
* @param {oldLibrary} library
* @param {string} userId
* @param {object} options
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getFilteredLibraryItems(library, userId, options) {
const { filterBy, sortBy, sortDesc, limit, offset, collapseseries, include, mediaType } = options
let filterValue = null let filterValue = null
let filterGroup = null let filterGroup = null
@ -17,7 +25,11 @@ module.exports = {
filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null
} }
// TODO: Handle podcast filters if (mediaType === 'book') {
return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) return libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset)
} else {
return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, userId, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset)
}
} }
} }

View File

@ -3,6 +3,13 @@ const Database = require('../../Database')
const Logger = require('../../Logger') const Logger = require('../../Logger')
module.exports = { module.exports = {
/**
* When collapsing series and filtering by progress
* different where options are required
*
* @param {string} value
* @returns {Sequelize.WhereOptions}
*/
getCollapseSeriesMediaProgressFilter(value) { getCollapseSeriesMediaProgressFilter(value) {
const mediaWhere = {} const mediaWhere = {}
if (value === 'not-finished') { if (value === 'not-finished') {
@ -52,10 +59,13 @@ module.exports = {
* Get where options for Book model * Get where options for Book model
* @param {string} group * @param {string} group
* @param {[string]} value * @param {[string]} value
* @returns {Sequelize.WhereOptions} * @returns {object} { Sequelize.WhereOptions, string[] }
*/ */
getMediaGroupQuery(group, value) { getMediaGroupQuery(group, value) {
if (!group) return { mediaWhere: {}, replacements: {} }
let mediaWhere = {} let mediaWhere = {}
const replacements = {}
if (group === 'progress') { if (group === 'progress') {
if (value === 'not-finished') { if (value === 'not-finished') {
@ -103,9 +113,10 @@ module.exports = {
} else if (group === 'abridged') { } else if (group === 'abridged') {
mediaWhere['abridged'] = true mediaWhere['abridged'] = true
} else if (['genres', 'tags', 'narrators'].includes(group)) { } else if (['genres', 'tags', 'narrators'].includes(group)) {
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = "${value}")`), { mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
[Sequelize.Op.gte]: 1 [Sequelize.Op.gte]: 1
}) })
replacements.filterValue = value
} else if (group === 'publishers') { } else if (group === 'publishers') {
mediaWhere['publisher'] = value mediaWhere['publisher'] = value
} else if (group === 'languages') { } else if (group === 'languages') {
@ -142,7 +153,7 @@ module.exports = {
} }
} }
return mediaWhere return { mediaWhere, replacements }
}, },
/** /**
@ -167,18 +178,18 @@ module.exports = {
} else if (sortBy === 'media.metadata.publishedYear') { } else if (sortBy === 'media.metadata.publishedYear') {
return [['publishedYear', dir]] return [['publishedYear', dir]]
} else if (sortBy === 'media.metadata.authorNameLF') { } else if (sortBy === 'media.metadata.authorNameLF') {
return [['author_name', dir]] return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]]
} else if (sortBy === 'media.metadata.authorName') { } else if (sortBy === 'media.metadata.authorName') {
return [['author_name', dir]] return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]]
} else if (sortBy === 'media.metadata.title') { } else if (sortBy === 'media.metadata.title') {
if (collapseseries) { if (collapseseries) {
return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]]
} }
if (global.ServerSettings.sortingIgnorePrefix) { if (global.ServerSettings.sortingIgnorePrefix) {
return [['titleIgnorePrefix', dir]] return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]]
} else { } else {
return [['title', dir]] return [[Sequelize.literal('title COLLATE NOCASE'), dir]]
} }
} else if (sortBy === 'sequence') { } else if (sortBy === 'sequence') {
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
@ -187,6 +198,15 @@ module.exports = {
return [] return []
}, },
/**
* When collapsing series get first book in each series
* to know which books to exclude from primary query.
* Additionally use this query to get the number of books in each series
*
* @param {Sequelize.ModelStatic} bookFindOptions
* @param {Sequelize.WhereOptions} seriesWhere
* @returns {object} { booksToExclude, bookSeriesToInclude }
*/
async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) { async getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) {
const allSeries = await Database.models.series.findAll({ const allSeries = await Database.models.series.findAll({
attributes: [ attributes: [
@ -386,7 +406,7 @@ module.exports = {
}) })
} }
const bookWhere = filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : {} const { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
let collapseSeriesBookSeries = [] let collapseSeriesBookSeries = []
if (collapseseries) { if (collapseseries) {
@ -399,7 +419,7 @@ module.exports = {
['$books.authors.id$']: null ['$books.authors.id$']: null
} }
} else { } else {
seriesBookWhere = bookWhere seriesBookWhere = mediaWhere
} }
const bookFindOptions = { const bookFindOptions = {
@ -417,12 +437,15 @@ module.exports = {
} }
const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere)
if (booksToExclude.length) { if (booksToExclude.length) {
bookWhere['id'] = { mediaWhere['id'] = {
[Sequelize.Op.notIn]: booksToExclude [Sequelize.Op.notIn]: booksToExclude
} }
} }
collapseSeriesBookSeries = bookSeriesToInclude collapseSeriesBookSeries = bookSeriesToInclude
if (!bookAttributes?.include) bookAttributes = { include: [] } if (!bookAttributes?.include) bookAttributes = { include: [] }
// When collapsing series and sorting by title then use the series name instead of the book title
// for this set an attribute "display_title" to use in sorting
if (global.ServerSettings.sortingIgnorePrefix) { 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']) 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 { } else {
@ -431,9 +454,10 @@ module.exports = {
} }
const { rows: books, count } = await Database.models.book.findAndCountAll({ const { rows: books, count } = await Database.models.book.findAndCountAll({
where: bookWhere, where: mediaWhere,
distinct: true, distinct: true,
attributes: bookAttributes, attributes: bookAttributes,
replacements,
include: [ include: [
{ {
model: Database.models.libraryItem, model: Database.models.libraryItem,

View File

@ -0,0 +1,156 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
const Logger = require('../../Logger')
module.exports = {
/**
* Get where options for Podcast model
* @param {string} group
* @param {[string]} value
* @returns {object} { Sequelize.WhereOptions, string[] }
*/
getMediaGroupQuery(group, value) {
if (!group) return { mediaWhere: {}, replacements: {} }
let mediaWhere = {}
const replacements = {}
if (['genres', 'tags'].includes(group)) {
mediaWhere[group] = Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(${group}) WHERE json_valid(${group}) AND json_each.value = :filterValue)`), {
[Sequelize.Op.gte]: 1
})
replacements.filterValue = value
}
return {
mediaWhere,
replacements
}
},
/**
* Get sequelize order
* @param {string} sortBy
* @param {boolean} sortDesc
* @returns {Sequelize.order}
*/
getOrder(sortBy, sortDesc) {
const dir = sortDesc ? 'DESC' : 'ASC'
if (sortBy === 'addedAt') {
return [[Sequelize.literal('libraryItem.createdAt'), dir]]
} else if (sortBy === 'size') {
return [[Sequelize.literal('libraryItem.size'), dir]]
} else if (sortBy === 'birthtimeMs') {
return [[Sequelize.literal('libraryItem.birthtime'), dir]]
} else if (sortBy === 'mtimeMs') {
return [[Sequelize.literal('libraryItem.mtime'), dir]]
} else if (sortBy === 'media.metadata.author') {
const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST'
return [[Sequelize.literal(`\`podcast\`.\`author\` COLLATE NOCASE ${nullDir}`)]]
} else if (sortBy === 'media.metadata.title') {
if (global.ServerSettings.sortingIgnorePrefix) {
return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]]
} else {
return [[Sequelize.literal('title COLLATE NOCASE'), dir]]
}
} else if (sortBy === 'media.numTracks') {
return [['numEpisodes', dir]]
}
return []
},
/**
* Get library items for podcast media type using filter and sort
* @param {string} libraryId
* @param {[string]} filterGroup
* @param {[string]} filterValue
* @param {string} sortBy
* @param {string} sortDesc
* @param {string[]} include
* @param {number} limit
* @param {number} offset
* @returns {object} { libraryItems:LibraryItem[], count:number }
*/
async getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) {
const includeRSSFeed = include.includes('rssfeed')
const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete')
const libraryItemWhere = {
libraryId
}
const libraryItemIncludes = []
if (includeRSSFeed) {
libraryItemIncludes.push({
model: Database.models.feed,
required: filterGroup === 'feed-open'
})
}
if (filterGroup === 'issues') {
libraryItemWhere[Sequelize.Op.or] = [
{
isMissing: true
},
{
isInvalid: true
}
]
}
const podcastIncludes = []
if (includeNumEpisodesIncomplete) {
podcastIncludes.push([Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = pe.id AND mp.userId = :userId WHERE pe.podcastId = podcast.id AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), 'numEpisodesIncomplete'])
}
const { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue)
replacements.userId = userId
const { rows: podcasts, count } = await Database.models.podcast.findAndCountAll({
where: mediaWhere,
replacements,
distinct: true,
attributes: {
include: [
[Sequelize.literal(`(SELECT count(*) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'numEpisodes'],
...podcastIncludes
]
},
include: [
{
model: Database.models.libraryItem,
required: true,
where: libraryItemWhere,
include: libraryItemIncludes
}
],
order: this.getOrder(sortBy, sortDesc),
subQuery: false,
limit,
offset
})
const libraryItems = podcasts.map((podcastExpanded) => {
const libraryItem = podcastExpanded.libraryItem.toJSON()
const podcast = podcastExpanded.toJSON()
delete podcast.libraryItem
if (libraryItem.feeds?.length) {
libraryItem.rssFeed = libraryItem.feeds[0]
}
if (podcast.numEpisodesIncomplete) {
libraryItem.numEpisodesIncomplete = podcast.numEpisodesIncomplete
}
libraryItem.media = podcast
return libraryItem
})
Logger.debug('Found', libraryItems.length, 'library items', 'total=', count)
return {
libraryItems,
count
}
}
}