From 8edab98163ad0de77567ba17a0c912ce4c3e7bb7 Mon Sep 17 00:00:00 2001 From: advplyr <advplyr@protonmail.com> Date: Fri, 4 Aug 2023 17:24:06 -0500 Subject: [PATCH] Update continue series shelf queries --- server/models/LibraryItem.js | 8 ++ server/utils/queries/libraryFilters.js | 25 ++++ .../utils/queries/libraryItemsBookFilters.js | 128 +++++++++++------- 3 files changed, 114 insertions(+), 47 deletions(-) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index 1b2bbfa5..e6525f45 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -437,6 +437,14 @@ module.exports = (sequelize) => { } } + /** + * Get home page data personalized shelves + * @param {oldLibrary} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @returns {object[]} array of shelf objects + */ static async getPersonalizedShelves(library, userId, include, limit) { const isPodcastLibrary = library.mediaType === 'podcast' const shelves = [] diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 55267f61..5a3db24b 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -33,6 +33,15 @@ module.exports = { } }, + /** + * Get library items for continue listening & continue reading shelves + * @param {oldLibrary} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @param {boolean} ebook true if continue reading shelf + * @returns {object} { libraryItems:LibraryItem[], count:number } + */ async getLibraryItemsInProgress(library, userId, include, limit, ebook = false) { if (library.mediaType === 'book') { const filterValue = ebook ? 'ebook-in-progress' : 'audio-in-progress' @@ -55,6 +64,14 @@ module.exports = { } }, + /** + * Get library items for most recently added shelf + * @param {oldLibrary} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @returns {object} { libraryItems:LibraryItem[], count:number } + */ async getLibraryItemsMostRecentlyAdded(library, userId, include, limit) { if (library.mediaType === 'book') { const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, null, null, 'addedAt', true, false, include, limit, 0) @@ -89,6 +106,14 @@ module.exports = { } }, + /** + * Get library items for continue series shelf + * @param {string} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @returns {object} { libraryItems:LibraryItem[], count:number } + */ async getLibraryItemsContinueSeries(library, userId, include, limit) { const { libraryItems, count } = await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, userId, include, limit, 0) return { diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 1c901428..c5f48c7c 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -215,7 +215,7 @@ module.exports = { } } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' - return [[Sequelize.literal(`\`series.bookSeries.sequence\` COLLATE NOCASE ${nullDir}`)]] + return [[Sequelize.literal(`CAST(\`series.bookSeries.sequence\` AS INTEGER) COLLATE NOCASE ${nullDir}`)]] } else if (sortBy === 'progress') { return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]] } @@ -253,7 +253,7 @@ module.exports = { } ], order: [ - Sequelize.literal('`books.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST') + Sequelize.literal('CAST(`books.bookSeries.sequence` AS INTEGER) COLLATE NOCASE ASC NULLS LAST') ] }) const bookSeriesToInclude = [] @@ -411,7 +411,7 @@ module.exports = { }) if (sortBy !== 'sequence') { // Secondary sort by sequence - sortOrder.push([Sequelize.literal('`series.bookSeries.sequence` COLLATE NOCASE ASC NULLS LAST')]) + sortOrder.push([Sequelize.literal('CAST(`series.bookSeries.sequence` AS INTEGER) COLLATE NOCASE ASC NULLS LAST')]) } } else if (filterGroup === 'issues') { libraryItemWhere[Sequelize.Op.or] = [ @@ -549,7 +549,22 @@ module.exports = { } }, + /** + * Get library items for continue series shelf + * A series is included on the shelf if it meets the following: + * 1. Has at least 1 finished book + * 2. Has no books in progress + * 3. Has at least 1 unfinished book + * TODO: Reduce queries + * @param {string} libraryId + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @param {number} offset + * @returns {object} { libraryItems:LibraryItem[], count:number } + */ async getContinueSeriesLibraryItems(libraryId, userId, include, limit, offset) { + // Step 1: Get all media progress for user that belongs to a series book const mediaProgressForUserForSeries = await Database.models.mediaProgress.findAll({ where: { userId @@ -571,68 +586,77 @@ module.exports = { ] }) - let seriesToIncludeMap = {} + // Step 1.5: Identify the series that have at least 1 finished book and have no books in progress + let seriesToInclude = [] let seriesToExclude = [] for (const prog of mediaProgressForUserForSeries) { const series = prog.mediaItem?.series || [] for (const s of series) { if (prog.currentTime > 0 && !prog.isFinished) { // in-progress - delete seriesToIncludeMap[s.id] + seriesToInclude = seriesToInclude.filter(sid => sid !== s.id) if (!seriesToExclude.includes(s.id)) seriesToExclude.push(s.id) - } else if (prog.isFinished && !seriesToExclude.includes(s.id)) { // finished - const lastUpdate = prog.updatedAt?.valueOf() || 0 - if (!seriesToIncludeMap[s.id] || lastUpdate > seriesToIncludeMap[s.id]) { - seriesToIncludeMap[s.id] = lastUpdate - } + } else if (prog.isFinished && !seriesToExclude.includes(s.id) && !seriesToInclude.includes(s.id)) { // finished + seriesToInclude.push(s.id) } } } + // optional include rssFeed with library item + const libraryItemIncludes = [] + if (include.includes('rssfeed')) { + libraryItemIncludes.push({ + model: Database.models.feed + }) + } + + // Step 2: Get all series identified in step 1.5 and filter out series where all books are finished const { rows: series, count } = await Database.models.series.findAndCountAll({ where: { id: { - [Sequelize.Op.in]: Object.keys(seriesToIncludeMap) + [Sequelize.Op.in]: seriesToInclude }, - '$books.mediaProgresses.isFinished$': { + '$bookSeries.book.mediaProgresses.isFinished$': { [Sequelize.Op.or]: [false, null] } }, distinct: true, include: [ { - model: Database.models.book, - through: { - attributes: ['sequence'] - }, - required: true, - include: [ - { - model: Database.models.libraryItem, - where: { - libraryId + model: Database.models.bookSeries, + include: { + model: Database.models.book, + include: [ + { + model: Database.models.libraryItem, + where: { + libraryId + }, + include: libraryItemIncludes + }, + { + model: Database.models.bookAuthor, + attributes: ['authorId'], + include: { + model: Database.models.author + }, + separate: true + }, + { + model: Database.models.mediaProgress, + where: { + userId + }, + required: false } - }, - { - model: Database.models.bookAuthor, - attributes: ['authorId'], - include: { - model: Database.models.author - }, - separate: true - }, - { - model: Database.models.mediaProgress, - where: { - userId - }, - required: false - } - ] + ], + required: true + }, + required: true } ], order: [ - [Sequelize.literal(`CAST(\`books.bookSeries.sequence\` AS INTEGER) COLLATE NOCASE ASC NULLS LAST`)], - [Sequelize.literal(`\`books.mediaProgresses.updatedAt\` DESC`)] + // Sort by progress most recently updated + [Database.models.bookSeries, Database.models.book, Database.models.mediaProgress, 'updatedAt', 'DESC'], ], subQuery: false, limit, @@ -641,20 +665,30 @@ module.exports = { Logger.debug('Found', series.length, 'series to continue', 'total=', count) + // Step 3: Map series to library items by selecting the first unfinished book in the series const libraryItems = series.map(s => { - const book = s.books.find(book => { - return !book.mediaProgresses?.[0]?.isFinished + // Natural sort sequence, nulls last + // TODO: sort in query. was unable to sort nested association with sequelize + 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' + }) }) - const libraryItem = book.libraryItem.toJSON() + + // Get first unfinished book to use + const bookSeries = s.bookSeries.find(bs => !bs.book.mediaProgresses?.[0]?.isFinished) + const libraryItem = bookSeries.book.libraryItem.toJSON() libraryItem.series = { id: s.id, name: s.name, - sequence: book.bookSeries.sequence + sequence: bookSeries.sequence } - delete book.bookSeries - libraryItem.media = book + libraryItem.media = bookSeries.book return libraryItem }) return {