diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 056cdfea..3bc98fde 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -314,8 +314,8 @@ export default { } 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)) { + // 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' } diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 9a67a4cc..efa280d0 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -207,7 +207,7 @@ class LibraryController { } 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.total = count diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 7795c414..c997da6d 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -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 { libraryItems: libraryItems.map(li => { const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified() @@ -414,6 +421,16 @@ module.exports = (sequelize) => { if (li.rssFeed) { 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 }), count diff --git a/server/models/Podcast.js b/server/models/Podcast.js index 5bc69fa9..f9d055c0 100644 --- a/server/models/Podcast.js +++ b/server/models/Podcast.js @@ -4,7 +4,7 @@ module.exports = (sequelize) => { class Podcast extends Model { static getOldPodcast(libraryItemExpanded) { 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 { id: podcastExpanded.id, libraryItemId: libraryItemExpanded.id, @@ -25,7 +25,7 @@ module.exports = (sequelize) => { }, coverPath: podcastExpanded.coverPath, tags: podcastExpanded.tags, - episodes: podcastEpisodes, + episodes: podcastEpisodes || [], autoDownloadEpisodes: podcastExpanded.autoDownloadEpisodes, autoDownloadSchedule: podcastExpanded.autoDownloadSchedule, lastEpisodeCheck: podcastExpanded.lastEpisodeCheck?.valueOf() || null, diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 4272b3c7..b9eb97e3 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -1,12 +1,20 @@ const libraryItemsBookFilters = require('./libraryItemsBookFilters') +const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') module.exports = { decode(text) { 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 filterGroup = null @@ -17,7 +25,11 @@ module.exports = { filterValue = group ? this.decode(filterBy.replace(`${group}.`, '')) : null } - // TODO: Handle podcast filters - return libraryItemsBookFilters.getFilteredLibraryItems(libraryId, userId, filterGroup, filterValue, sortBy, sortDesc, collapseseries, include, limit, offset) + if (mediaType === 'book') { + 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) + } + } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 0b2d4ede..07214e91 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -3,6 +3,13 @@ const Database = require('../../Database') const Logger = require('../../Logger') module.exports = { + /** + * When collapsing series and filtering by progress + * different where options are required + * + * @param {string} value + * @returns {Sequelize.WhereOptions} + */ getCollapseSeriesMediaProgressFilter(value) { const mediaWhere = {} if (value === 'not-finished') { @@ -52,10 +59,13 @@ module.exports = { * Get where options for Book model * @param {string} group * @param {[string]} value - * @returns {Sequelize.WhereOptions} + * @returns {object} { Sequelize.WhereOptions, string[] } */ getMediaGroupQuery(group, value) { + if (!group) return { mediaWhere: {}, replacements: {} } + let mediaWhere = {} + const replacements = {} if (group === 'progress') { if (value === 'not-finished') { @@ -103,9 +113,10 @@ module.exports = { } else if (group === 'abridged') { mediaWhere['abridged'] = true } 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 }) + replacements.filterValue = value } else if (group === 'publishers') { mediaWhere['publisher'] = value } 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') { return [['publishedYear', dir]] } else if (sortBy === 'media.metadata.authorNameLF') { - return [['author_name', dir]] + return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.authorName') { - return [['author_name', dir]] + return [[Sequelize.literal('author_name COLLATE NOCASE'), dir]] } else if (sortBy === 'media.metadata.title') { if (collapseseries) { return [[Sequelize.literal('display_title COLLATE NOCASE'), dir]] } if (global.ServerSettings.sortingIgnorePrefix) { - return [['titleIgnorePrefix', dir]] + return [[Sequelize.literal('titleIgnorePrefix COLLATE NOCASE'), dir]] } else { - return [['title', dir]] + return [[Sequelize.literal('title COLLATE NOCASE'), dir]] } } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' @@ -187,6 +198,15 @@ module.exports = { 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) { const allSeries = await Database.models.series.findAll({ attributes: [ @@ -386,7 +406,7 @@ module.exports = { }) } - const bookWhere = filterGroup ? this.getMediaGroupQuery(filterGroup, filterValue) : {} + const { mediaWhere, replacements } = this.getMediaGroupQuery(filterGroup, filterValue) let collapseSeriesBookSeries = [] if (collapseseries) { @@ -399,7 +419,7 @@ module.exports = { ['$books.authors.id$']: null } } else { - seriesBookWhere = bookWhere + seriesBookWhere = mediaWhere } const bookFindOptions = { @@ -417,12 +437,15 @@ module.exports = { } const { booksToExclude, bookSeriesToInclude } = await this.getCollapseSeriesBooksToExclude(bookFindOptions, seriesWhere) if (booksToExclude.length) { - bookWhere['id'] = { + mediaWhere['id'] = { [Sequelize.Op.notIn]: booksToExclude } } collapseSeriesBookSeries = bookSeriesToInclude 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) { 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 { @@ -431,9 +454,10 @@ module.exports = { } const { rows: books, count } = await Database.models.book.findAndCountAll({ - where: bookWhere, + where: mediaWhere, distinct: true, attributes: bookAttributes, + replacements, include: [ { model: Database.models.libraryItem, diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js new file mode 100644 index 00000000..e71d9740 --- /dev/null +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -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 + } + } +} \ No newline at end of file