From b9633691f4803c07460bd36e059711f34dbbc350 Mon Sep 17 00:00:00 2001 From: advplyr Date: Wed, 2 Aug 2023 18:29:28 -0500 Subject: [PATCH] Add new personalized home page shelves API endpoint --- server/controllers/LibraryController.js | 20 ++++- server/models/LibraryItem.js | 47 ++++++++++++ server/routers/ApiRouter.js | 1 + server/utils/queries/libraryFilters.js | 60 +++++++++++++++ .../utils/queries/libraryItemsBookFilters.js | 76 ++++++++++++++++++- 5 files changed, 201 insertions(+), 3 deletions(-) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index efa280d0..917ea247 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -631,8 +631,24 @@ class LibraryController { res.json(libraryHelpers.getDistinctFilterDataNew(req.libraryItems)) } - // api/libraries/:id/personalized - // New and improved personalized call only loops through library items once + /** + * GET: /api/libraries/:id/personalized2 + * TODO: new endpoint + * @param {*} req + * @param {*} res + */ + async getUserPersonalizedShelves(req, res) { + const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10 + const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) + const shelves = await Database.models.libraryItem.getPersonalizedShelves(req.library, req.user.id, include, limitPerShelf) + res.json(shelves) + } + + /** + * GET: /api/libraries/:id/personalized + * @param {*} req + * @param {*} res + */ async getLibraryUserPersonalizedOptimal(req, res) { const limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) || 10 : 10 const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index c997da6d..e68a9736 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -437,6 +437,53 @@ module.exports = (sequelize) => { } } + static async getPersonalizedShelves(library, userId, include, limit) { + const isPodcastLibrary = library.mediaType === 'podcast' + const shelves = [] + const itemsInProgressPayload = await libraryFilters.getLibraryItemsInProgress(library, userId, include, limit, false) + if (itemsInProgressPayload.libraryItems.length) { + shelves.push({ + id: 'continue-listening', + label: 'Continue Listening', + labelStringKey: 'LabelContinueListening', + type: isPodcastLibrary ? 'episode' : 'book', + entities: itemsInProgressPayload.libraryItems, + total: itemsInProgressPayload.count + }) + } + + if (library.mediaType === 'book') { + const ebooksInProgressPayload = await libraryFilters.getLibraryItemsInProgress(library, userId, include, limit, true) + if (ebooksInProgressPayload.libraryItems.length) { + shelves.push({ + id: 'continue-reading', + label: 'Continue Reading', + labelStringKey: 'LabelContinueReading', + type: 'book', + entities: ebooksInProgressPayload.libraryItems, + total: ebooksInProgressPayload.count + }) + } + } + + const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, userId, include, limit) + if (mostRecentPayload.libraryItems.length) { + shelves.push({ + id: 'recently-added', + label: 'Recently Added', + labelStringKey: 'LabelRecentlyAdded', + type: library.mediaType, + entities: mostRecentPayload.libraryItems, + total: mostRecentPayload.count + }) + } + + // TODO: Handle continue series library items + const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, userId, include, limit) + + return shelves + } + getMedia(options) { if (!this.mediaType) return Promise.resolve(null) const mixinMethodName = `get${sequelize.uppercaseFirst(this.mediaType)}` diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 02e55d74..9f811ad4 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -83,6 +83,7 @@ class ApiRouter { this.router.get('/libraries/:id/collections', LibraryController.middleware.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) this.router.get('/libraries/:id/playlists', LibraryController.middleware.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) this.router.get('/libraries/:id/albums', LibraryController.middleware.bind(this), LibraryController.getAlbumsForLibrary.bind(this)) + this.router.get('/libraries/:id/personalized2', LibraryController.middleware.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middleware.bind(this), LibraryController.getLibraryFilterData.bind(this)) this.router.get('/libraries/:id/search', LibraryController.middleware.bind(this), LibraryController.search.bind(this)) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index b9eb97e3..82ae64a1 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -1,3 +1,4 @@ +const Database = require('../../Database') const libraryItemsBookFilters = require('./libraryItemsBookFilters') const libraryItemsPodcastFilters = require('./libraryItemsPodcastFilters') @@ -30,6 +31,65 @@ module.exports = { } else { return libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, userId, filterGroup, filterValue, sortBy, sortDesc, include, limit, offset) } + }, + async getLibraryItemsInProgress(library, userId, include, limit, ebook = false) { + if (library.mediaType === 'book') { + const filterValue = ebook ? 'ebook-in-progress' : 'audio-in-progress' + const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, 'progress', filterValue, 'progress', true, false, include, limit, 0) + return { + libraryItems: libraryItems.map(li => { + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() + if (li.rssFeed) { + oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified() + } + return oldLibraryItem + }), + count + } + } else { + return { + count: 0, + libraryItems: [] + } + } + }, + + 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) + return { + libraryItems: libraryItems.map(li => { + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() + if (li.rssFeed) { + oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified() + } + if (li.size && !oldLibraryItem.media.size) { + oldLibraryItem.media.size = li.size + } + return oldLibraryItem + }), + count + } + } else { + const { libraryItems, count } = await libraryItemsPodcastFilters.getFilteredLibraryItems(library.id, userId, null, null, 'addedAt', true, include, limit, 0) + return { + libraryItems: libraryItems.map(li => { + const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() + if (li.rssFeed) { + oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified() + } + if (li.size && !oldLibraryItem.media.size) { + oldLibraryItem.media.size = li.size + } + return oldLibraryItem + }), + count + } + } + }, + + async getLibraryItemsContinueSeries(library, userId, include, limit) { + await libraryItemsBookFilters.getContinueSeriesLibraryItems(library.id, userId, limit, 0) } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 07214e91..2f6f7651 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -107,6 +107,28 @@ module.exports = { '$mediaProgresses.isFinished$': false } ] + } else if (value === 'audio-in-progress') { + mediaWhere[Sequelize.Op.and] = [ + { + '$mediaProgresses.currentTime$': { + [Sequelize.Op.gt]: 0 + } + }, + { + '$mediaProgresses.isFinished$': false + } + ] + } else if (value === 'ebook-in-progress') { + mediaWhere[Sequelize.Op.and] = [ + { + '$mediaProgresses.ebookProgress$': { + [Sequelize.Op.gt]: 0 + } + }, + { + '$mediaProgresses.isFinished$': false + } + ] } } else if (group === 'series' && value === 'no-series') { mediaWhere['$series.id$'] = null @@ -194,6 +216,8 @@ module.exports = { } else if (sortBy === 'sequence') { const nullDir = sortDesc ? 'DESC NULLS FIRST' : 'ASC NULLS LAST' return [[Sequelize.literal(`\`series.bookSeries.sequence\` COLLATE NOCASE ${nullDir}`)]] + } else if (sortBy === 'progress') { + return [[Sequelize.literal('mediaProgresses.updatedAt'), dir]] } return [] }, @@ -275,6 +299,9 @@ module.exports = { if (filterGroup !== 'series' && sortBy === 'sequence') { sortBy = 'media.metadata.title' } + if (filterGroup !== 'progress' && sortBy === 'progress') { + sortBy = 'media.metadata.title' + } const includeRSSFeed = include.includes('rssfeed') // For sorting by author name an additional attribute must be added @@ -398,7 +425,7 @@ module.exports = { } else if (filterGroup === 'progress') { bookIncludes.push({ model: Database.models.mediaProgress, - attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress'], + attributes: ['id', 'isFinished', 'currentTime', 'ebookProgress', 'updatedAt'], where: { userId }, @@ -520,5 +547,52 @@ module.exports = { libraryItems, count } + }, + + async getContinueSeriesLibraryItems(libraryId, userId, limit, offset) { + const { rows: series, count } = await Database.models.series.findAndCountAll({ + where: [ + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = b.id AND mp.userId = :userId AND bs.bookId = b.id AND bs.seriesId = series.id AND mp.isFinished = 1)`), { + [Sequelize.Op.gt]: 0 + }), + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM books b, bookSeries bs, mediaProgresses mp WHERE mp.mediaItemId = b.id AND mp.userId = :userId AND bs.bookId = b.id AND bs.seriesId = series.id AND mp.isFinished = 0 AND mp.currentTime > 0)`), 0), + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.userId = :userId AND mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND (mp.currentTime = 0 OR mp.currentTime IS NULL) AND (mp.isFinished = 0 OR mp.isFinished IS NULL))`), { + [Sequelize.Op.gt]: 0 + }) + ], + replacements: { + userId + }, + distinct: true, + include: [ + { + model: Database.models.book, + through: { + attributes: ['sequence'] + }, + include: [ + { + model: Database.models.libraryItem, + where: { + libraryId + } + }, + { + model: Database.models.author, + attributes: ['id', 'name'], + through: { + attributes: [] + } + } + ] + } + ], + order: [[Sequelize.literal(`\`books.bookSeries.sequence\` COLLATE NOCASE ASC NULLS LAST`)]], + subQuery: false, + limit, + offset + }) + + Logger.debug('Found', series.length, 'series to continue', 'total=', count) } } \ No newline at end of file