diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index 40793610..fbed11be 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -171,7 +171,7 @@ export default { }, async fetchCategories() { const categories = await this.$axios - .$get(`/api/libraries/${this.currentLibraryId}/personalized2?include=rssfeed,numEpisodesIncomplete`) + .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`) .then((data) => { return data }) diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 509d3f24..259b31ae 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -317,8 +317,6 @@ export default { // 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' - } else if (entityPath === 'series') { - entityPath += '2' } const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : '' diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 502d1cd5..f49f5539 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -474,8 +474,8 @@ class LibraryController { /** * DELETE: /libraries/:id/issues * Remove all library items missing or invalid - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async removeLibraryItemsWithIssues(req, res) { const libraryItemsWithIssues = await Database.models.libraryItem.findAll({ @@ -510,7 +510,7 @@ class LibraryController { Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`) for (const libraryItem of libraryItemsWithIssues) { let mediaItemIds = [] - if (library.isPodcast) { + if (req.library.isPodcast) { mediaItemIds = libraryItem.media.podcastEpisodes.map(pe => pe.id) } else { mediaItemIds.push(libraryItem.mediaId) @@ -523,13 +523,13 @@ class LibraryController { } /** - * GET: /api/libraries/:id/series2 + * GET: /api/libraries/:id/series * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open * * @param {import('express').Request} req * @param {import('express').Response} res */ - async getAllSeriesForLibraryNew(req, res) { + async getAllSeriesForLibrary(req, res) { const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) const payload = { @@ -552,73 +552,6 @@ class LibraryController { res.json(payload) } - /** - * GET: /api/libraries/:id/series - * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open - * - * @param {import('express').Request} req - * @param {import('express').Response} res - */ - async getAllSeriesForLibrary(req, res) { - const libraryItems = req.libraryItems - - const include = (req.query.include || '').split(',').map(v => v.trim().toLowerCase()).filter(v => !!v) - - const payload = { - results: [], - total: 0, - limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0, - page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0, - sortBy: req.query.sort, - sortDesc: req.query.desc === '1', - filterBy: req.query.filter, - minified: req.query.minified === '1', - include: include.join(',') - } - - let series = libraryHelpers.getSeriesFromBooks(libraryItems, Database.series, null, payload.filterBy, req.user, payload.minified, req.library.settings.hideSingleBookSeries) - - const direction = payload.sortDesc ? 'desc' : 'asc' - series = naturalSort(series).by([ - { - [direction]: (se) => { - if (payload.sortBy === 'numBooks') { - return se.books.length - } else if (payload.sortBy === 'totalDuration') { - return se.totalDuration - } else if (payload.sortBy === 'addedAt') { - return se.addedAt - } else if (payload.sortBy === 'lastBookUpdated') { - return Math.max(...(se.books).map(x => x.updatedAt), 0) - } else if (payload.sortBy === 'lastBookAdded') { - return Math.max(...(se.books).map(x => x.addedAt), 0) - } else { // sort by name - return Database.serverSettings.sortingIgnorePrefix ? se.nameIgnorePrefixSort : se.name - } - } - } - ]) - - payload.total = series.length - - if (payload.limit) { - const startIndex = payload.page * payload.limit - series = series.slice(startIndex, startIndex + payload.limit) - } - - // add rssFeed when "include=rssfeed" is in query string - if (include.includes('rssfeed')) { - series = await Promise.all(series.map(async (se) => { - const feedData = await this.rssFeedManager.findFeedForEntityId(se.id) - se.rssFeed = feedData?.toJSONMinified() || null - return se - })) - } - - payload.results = series - res.json(payload) - } - /** * GET: /api/libraries/:id/series/:seriesId * @@ -718,8 +651,8 @@ class LibraryController { /** * GET: /api/libraries/:id/filterdata - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async getLibraryFilterData(req, res) { const filterData = await libraryFilters.getFilterData(req.library) @@ -727,37 +660,23 @@ class LibraryController { } /** - * GET: /api/libraries/:id/personalized2 - * TODO: new endpoint - * @param {*} req - * @param {*} res + * GET: /api/libraries/:id/personalized + * Home page shelves + * @param {import('express').Request} req + * @param {import('express').Response} 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, include, limitPerShelf) + const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf) res.json(shelves) } - /** - * GET: /api/libraries/:id/personalized - * TODO: remove after personalized2 is ready - * @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) - - const categories = await libraryHelpers.buildPersonalizedShelves(this, req.user, req.libraryItems, req.library, limitPerShelf, include) - res.json(categories) - } - /** * POST: /api/libraries/order * Change the display order of libraries - * @param {*} req - * @param {*} res + * @param {import('express').Request} req + * @param {import('express').Response} res */ async reorder(req, res) { if (!req.user.isAdminOrUp) { diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 9e3b5474..74a069c0 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -78,13 +78,11 @@ class ApiRouter { this.router.get('/libraries/:id/items', LibraryController.middleware.bind(this), LibraryController.getLibraryItems.bind(this)) this.router.delete('/libraries/:id/issues', LibraryController.middlewareNew.bind(this), LibraryController.removeLibraryItemsWithIssues.bind(this)) this.router.get('/libraries/:id/episode-downloads', LibraryController.middlewareNew.bind(this), LibraryController.getEpisodeDownloadQueue.bind(this)) - this.router.get('/libraries/:id/series2', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibraryNew.bind(this)) - this.router.get('/libraries/:id/series', LibraryController.middleware.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) + this.router.get('/libraries/:id/series', LibraryController.middlewareNew.bind(this), LibraryController.getAllSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/series/:seriesId', LibraryController.middlewareNew.bind(this), LibraryController.getSeriesForLibrary.bind(this)) this.router.get('/libraries/:id/collections', LibraryController.middlewareNew.bind(this), LibraryController.getCollectionsForLibrary.bind(this)) this.router.get('/libraries/:id/playlists', LibraryController.middlewareNew.bind(this), LibraryController.getUserPlaylistsForLibrary.bind(this)) - this.router.get('/libraries/:id/personalized2', LibraryController.middlewareNew.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/personalized', LibraryController.middlewareNew.bind(this), LibraryController.getUserPersonalizedShelves.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this)) this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this)) this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this)) @@ -445,9 +443,9 @@ class ApiRouter { /** * Used when a series is removed from a book * Series is removed if it only has 1 book - * TODO: Update filter data - * @param {UUIDV4} bookId - * @param {UUIDV4[]} seriesIds + * + * @param {string} bookId + * @param {string[]} seriesIds */ async checkRemoveEmptySeries(bookId, seriesIds) { if (!seriesIds?.length) return diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 20806a7a..7c7b8a07 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -1,5 +1,4 @@ -const { sort, createNewSortInstance } = require('../libs/fastSort') -const Logger = require('../Logger') +const { createNewSortInstance } = require('../libs/fastSort') const Database = require('../Database') const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index') const naturalSort = createNewSortInstance({ @@ -126,60 +125,6 @@ module.exports = { return true }, - getDistinctFilterDataNew(libraryItems) { - const data = { - authors: [], - genres: [], - tags: [], - series: [], - narrators: [], - languages: [], - publishers: [] - } - libraryItems.forEach((li) => { - const mediaMetadata = li.media.metadata - if (mediaMetadata.authors?.length) { - mediaMetadata.authors.forEach((author) => { - if (author && !data.authors.some(au => au.id === author.id)) data.authors.push({ id: author.id, name: author.name }) - }) - } - if (mediaMetadata.series?.length) { - mediaMetadata.series.forEach((series) => { - if (series && !data.series.some(se => se.id === series.id)) data.series.push({ id: series.id, name: series.name }) - }) - } - if (mediaMetadata.genres?.length) { - mediaMetadata.genres.forEach((genre) => { - if (genre && !data.genres.includes(genre)) data.genres.push(genre) - }) - } - if (li.media.tags.length) { - li.media.tags.forEach((tag) => { - if (tag && !data.tags.includes(tag)) data.tags.push(tag) - }) - } - if (mediaMetadata.narrators?.length) { - mediaMetadata.narrators.forEach((narrator) => { - if (narrator && !data.narrators.includes(narrator)) data.narrators.push(narrator) - }) - } - if (mediaMetadata.publisher && !data.publishers.includes(mediaMetadata.publisher)) { - data.publishers.push(mediaMetadata.publisher) - } - if (mediaMetadata.language && !data.languages.includes(mediaMetadata.language)) { - data.languages.push(mediaMetadata.language) - } - }) - data.authors = naturalSort(data.authors).asc(au => au.name) - data.genres = naturalSort(data.genres).asc() - data.tags = naturalSort(data.tags).asc() - data.series = naturalSort(data.series).asc(se => se.name) - data.narrators = naturalSort(data.narrators).asc() - data.publishers = naturalSort(data.publishers).asc() - data.languages = naturalSort(data.languages).asc() - return data - }, - getSeriesFromBooks(books, allSeries, filterSeries, filterBy, user, minified, hideSingleBookSeries) { const _series = {} const seriesToFilterOut = {} @@ -246,89 +191,6 @@ module.exports = { }) }, - getBooksNextInSeries(seriesWithUserAb, limit, minified = false) { - var incompleteSeires = seriesWithUserAb.filter((series) => series.books.some((book) => !book.userAudiobook || (!book.userAudiobook.isRead && book.userAudiobook.progress == 0))) - var booksNextInSeries = [] - incompleteSeires.forEach((series) => { - var dateLastRead = series.books.filter((data) => data.userAudiobook && data.userAudiobook.isRead).sort((a, b) => { return b.userAudiobook.finishedAt - a.userAudiobook.finishedAt })[0].userAudiobook.finishedAt - var nextUnreadBook = series.books.filter((data) => !data.userAudiobook || (!data.userAudiobook.isRead && data.userAudiobook.progress == 0))[0] - nextUnreadBook.DateLastReadSeries = dateLastRead - booksNextInSeries.push(nextUnreadBook) - }) - return booksNextInSeries.sort((a, b) => { return b.DateLastReadSeries - a.DateLastReadSeries }).map(b => minified ? b.book.toJSONMinified() : b.book.toJSONExpanded()).slice(0, limit) - }, - - getGenresWithCount(libraryItems) { - var genresMap = {} - libraryItems.forEach((li) => { - var genres = li.media.metadata.genres || [] - genres.forEach((genre) => { - if (genresMap[genre]) genresMap[genre].count++ - else - genresMap[genre] = { - genre, - count: 1 - } - }) - }) - return Object.values(genresMap).sort((a, b) => b.count - a.count) - }, - - getAuthorsWithCount(libraryItems) { - var authorsMap = {} - libraryItems.forEach((li) => { - var authors = li.media.metadata.authors || [] - authors.forEach((author) => { - if (authorsMap[author.id]) authorsMap[author.id].count++ - else - authorsMap[author.id] = { - id: author.id, - name: author.name, - count: 1 - } - }) - }) - return Object.values(authorsMap).sort((a, b) => b.count - a.count) - }, - - getItemDurationStats(libraryItems) { - var sorted = sort(libraryItems).desc(li => li.media.duration) - var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, duration: li.media.duration })).filter(i => i.duration > 0) - var totalDuration = 0 - var numAudioTracks = 0 - libraryItems.forEach((li) => { - totalDuration += li.media.duration - numAudioTracks += li.media.numTracks - }) - return { - totalDuration, - numAudioTracks, - longestItems: top10 - } - }, - - getItemSizeStats(libraryItems) { - var sorted = sort(libraryItems).desc(li => li.media.size) - var top10 = sorted.slice(0, 10).map(li => ({ id: li.id, title: li.media.metadata.title, size: li.media.size })).filter(i => i.size > 0) - var totalSize = 0 - libraryItems.forEach((li) => { - totalSize += li.media.size - }) - return { - totalSize, - largestItems: top10 - } - }, - - getLibraryItemsTotalSize(libraryItems) { - var totalSize = 0 - libraryItems.forEach((li) => { - totalSize += li.media.size - }) - return totalSize - }, - - collapseBookSeries(libraryItems, series, filterSeries, hideSingleBookSeries) { // Get series from the library items. If this list is being collapsed after filtering for a series, // don't collapse that series, only books that are in other series. @@ -356,550 +218,5 @@ module.exports = { }) return filteredLibraryItems - }, - - async buildPersonalizedShelves(ctx, user, libraryItems, library, maxEntitiesPerShelf, include) { - const mediaType = library.mediaType - const isPodcastLibrary = mediaType === 'podcast' - const includeRssFeed = include.includes('rssfeed') - const includeNumEpisodesIncomplete = include.includes('numepisodesincomplete') // Podcasts only - const hideSingleBookSeries = library.settings.hideSingleBookSeries - - const shelves = [ - { - id: 'continue-listening', - label: 'Continue Listening', - labelStringKey: 'LabelContinueListening', - type: isPodcastLibrary ? 'episode' : mediaType, - entities: [] - }, - { - id: 'continue-reading', - label: 'Continue Reading', - labelStringKey: 'LabelContinueReading', - type: 'book', - entities: [] - }, - { - id: 'continue-series', - label: 'Continue Series', - labelStringKey: 'LabelContinueSeries', - type: mediaType, - entities: [] - }, - { - id: 'episodes-recently-added', - label: 'Newest Episodes', - labelStringKey: 'LabelNewestEpisodes', - type: 'episode', - entities: [] - }, - { - id: 'recently-added', - label: 'Recently Added', - labelStringKey: 'LabelRecentlyAdded', - type: mediaType, - entities: [] - }, - { - id: 'recent-series', - label: 'Recent Series', - labelStringKey: 'LabelRecentSeries', - type: 'series', - entities: [] - }, - { - id: 'recommended', - label: 'Recommended', - labelStringKey: 'LabelRecommended', - type: mediaType, - entities: [] - }, - { - id: 'listen-again', - label: 'Listen Again', - labelStringKey: 'LabelListenAgain', - type: isPodcastLibrary ? 'episode' : mediaType, - entities: [] - }, - { - id: 'read-again', - label: 'Read Again', - labelStringKey: 'LabelReadAgain', - type: 'book', - entities: [] - }, - { - id: 'newest-authors', - label: 'Newest Authors', - labelStringKey: 'LabelNewestAuthors', - type: 'authors', - entities: [] - } - ] - - const categoryMap = {} - shelves.forEach((shelf) => { - categoryMap[shelf.id] = { - id: shelf.id, - biggest: 0, - smallest: 0, - items: [] - } - }) - - const seriesMap = {} - const authorMap = {} - - // For use with recommended - const topGenresListened = {} - const topAuthorsListened = {} - const topTagsListened = {} - const notStartedBooks = [] - - for (const libraryItem of libraryItems) { - if (libraryItem.addedAt > categoryMap['recently-added'].smallest) { - const libraryItemObj = libraryItem.toJSONMinified() - - // add numEpisodesIncomplete if "include=numEpisodesIncomplete" was put in query string (only for podcasts) - if (includeNumEpisodesIncomplete && libraryItem.isPodcast) { - libraryItemObj.numEpisodesIncomplete = user.getNumEpisodesIncompleteForPodcast(libraryItem) - } - - const indexToPut = categoryMap['recently-added'].items.findIndex(i => libraryItem.addedAt > i.addedAt) - if (indexToPut >= 0) { - categoryMap['recently-added'].items.splice(indexToPut, 0, libraryItemObj) - } else { - categoryMap['recently-added'].items.push(libraryItemObj) - } - - if (categoryMap['recently-added'].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap['recently-added'].items.pop() - categoryMap['recently-added'].smallest = categoryMap['recently-added'].items[categoryMap['recently-added'].items.length - 1].addedAt - } - categoryMap['recently-added'].biggest = categoryMap['recently-added'].items[0].addedAt - } - - const allItemProgress = user.getAllMediaProgressForLibraryItem(libraryItem.id) - if (libraryItem.isPodcast) { - // Podcast categories - const podcastEpisodes = libraryItem.media.episodes || [] - for (const episode of podcastEpisodes) { - const mediaProgress = allItemProgress.find(mp => mp.episodeId === episode.id) - - // Newest episodes - if (!mediaProgress?.isFinished && episode.addedAt > categoryMap['episodes-recently-added'].smallest) { - const libraryItemWithEpisode = { - ...libraryItem.toJSONMinified(), - recentEpisode: episode.toJSON() - } - - const indexToPut = categoryMap['episodes-recently-added'].items.findIndex(i => episode.addedAt > i.recentEpisode.addedAt) - if (indexToPut >= 0) { - categoryMap['episodes-recently-added'].items.splice(indexToPut, 0, libraryItemWithEpisode) - } else { - categoryMap['episodes-recently-added'].items.push(libraryItemWithEpisode) - } - - if (categoryMap['episodes-recently-added'].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap['episodes-recently-added'].items.pop() - categoryMap['episodes-recently-added'].smallest = categoryMap['episodes-recently-added'].items[categoryMap['episodes-recently-added'].items.length - 1].recentEpisode.addedAt - } - categoryMap['episodes-recently-added'].biggest = categoryMap['episodes-recently-added'].items[0].recentEpisode.addedAt - } - - // Episode recently listened and finished - if (mediaProgress) { - if (mediaProgress.isFinished) { - if (mediaProgress.finishedAt > categoryMap['listen-again'].smallest) { // Item belongs on shelf - const libraryItemWithEpisode = { - ...libraryItem.toJSONMinified(), - recentEpisode: episode.toJSON(), - finishedAt: mediaProgress.finishedAt - } - - const indexToPut = categoryMap['listen-again'].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt) - if (indexToPut >= 0) { - categoryMap['listen-again'].items.splice(indexToPut, 0, libraryItemWithEpisode) - } else { - categoryMap['listen-again'].items.push(libraryItemWithEpisode) - } - - if (categoryMap['listen-again'].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap['listen-again'].items.pop() - categoryMap['listen-again'].smallest = categoryMap['listen-again'].items[categoryMap['listen-again'].items.length - 1].finishedAt - } - categoryMap['listen-again'].biggest = categoryMap['listen-again'].items[0].finishedAt - } - } else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened - if (mediaProgress.lastUpdate > categoryMap['continue-listening'].smallest) { // Item belongs on shelf - const libraryItemWithEpisode = { - ...libraryItem.toJSONMinified(), - recentEpisode: episode.toJSON(), - progressLastUpdate: mediaProgress.lastUpdate - } - - const indexToPut = categoryMap['continue-listening'].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate) - if (indexToPut >= 0) { - categoryMap['continue-listening'].items.splice(indexToPut, 0, libraryItemWithEpisode) - } else { - categoryMap['continue-listening'].items.push(libraryItemWithEpisode) - } - - if (categoryMap['continue-listening'].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap['continue-listening'].items.pop() - categoryMap['continue-listening'].smallest = categoryMap['continue-listening'].items[categoryMap['continue-listening'].items.length - 1].progressLastUpdate - } - - categoryMap['continue-listening'].biggest = categoryMap['continue-listening'].items[0].progressLastUpdate - } - } - } - } - } else if (libraryItem.isBook) { - // Book categories - - const mediaProgress = allItemProgress.length ? allItemProgress[0] : null - - // Used for recommended. Tally up most listened to authors/genres/tags - if (mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished)) { - libraryItem.media.metadata.authors.forEach((author) => { - topAuthorsListened[author.id] = (topAuthorsListened[author.id] || 0) + 1 - }) - libraryItem.media.metadata.genres.forEach((genre) => { - topGenresListened[genre] = (topGenresListened[genre] || 0) + 1 - }) - libraryItem.media.tags.forEach((tag) => { - topTagsListened[tag] = (topTagsListened[tag] || 0) + 1 - }) - } else { - // Insert in random position to add randomization to equal weighted items - notStartedBooks.splice(Math.floor(Math.random() * (notStartedBooks.length + 1)), 0, libraryItem) - } - - // Newest series - if (libraryItem.media.metadata.series.length) { - for (const librarySeries of libraryItem.media.metadata.series) { - - const bookInProgress = mediaProgress && (mediaProgress.inProgress || mediaProgress.isFinished) - const bookActive = mediaProgress && mediaProgress.inProgress && !mediaProgress.isFinished - const libraryItemJson = libraryItem.toJSONMinified() - libraryItemJson.seriesSequence = librarySeries.sequence - - const hideFromContinueListening = user.checkShouldHideSeriesFromContinueListening(librarySeries.id) - - if (!seriesMap[librarySeries.id]) { - const seriesObj = Database.series.find(se => se.id === librarySeries.id) - if (seriesObj) { - const series = { - ...seriesObj.toJSON(), - books: [libraryItemJson], - inProgress: bookInProgress, - hasActiveBook: bookActive, - hideFromContinueListening, - bookInProgressLastUpdate: bookInProgress ? mediaProgress.lastUpdate : null, - firstBookUnread: bookInProgress ? null : libraryItemJson - } - seriesMap[librarySeries.id] = series - - const indexToPut = categoryMap['recent-series'].items.findIndex(i => series.addedAt > i.addedAt) - if (indexToPut >= 0) { - categoryMap['recent-series'].items.splice(indexToPut, 0, series) - } else { - categoryMap['recent-series'].items.push(series) - } - } - } else { - // series already in map - add book - seriesMap[librarySeries.id].books.push(libraryItemJson) - - if (bookInProgress) { // Update if this series is in progress - seriesMap[librarySeries.id].inProgress = true - - if (seriesMap[librarySeries.id].bookInProgressLastUpdate < mediaProgress.lastUpdate) { - seriesMap[librarySeries.id].bookInProgressLastUpdate = mediaProgress.lastUpdate - } - } else if (!seriesMap[librarySeries.id].firstBookUnread) { - seriesMap[librarySeries.id].firstBookUnread = libraryItemJson - } else if (libraryItemJson.seriesSequence) { - // If current firstBookUnread has a series sequence greater than this series sequence, then update firstBookUnread - const firstBookUnreadSequence = seriesMap[librarySeries.id].firstBookUnread.seriesSequence - if (!firstBookUnreadSequence || String(firstBookUnreadSequence).localeCompare(String(librarySeries.sequence), undefined, { sensitivity: 'base', numeric: true }) > 0) { - seriesMap[librarySeries.id].firstBookUnread = libraryItemJson - } - } - - // Update if series has an active (progress < 100%) book - if (bookActive) { - seriesMap[librarySeries.id].hasActiveBook = true - } - } - } - } - - // Newest authors - if (libraryItem.media.metadata.authors.length) { - for (const libraryAuthor of libraryItem.media.metadata.authors) { - if (!authorMap[libraryAuthor.id]) { - const authorObj = Database.authors.find(au => au.id === libraryAuthor.id) - if (authorObj) { - const author = { - ...authorObj.toJSON(), - numBooks: 1 - } - - if (author.addedAt > categoryMap['newest-authors'].smallest) { - - const indexToPut = categoryMap['newest-authors'].items.findIndex(i => author.addedAt > i.addedAt) - if (indexToPut >= 0) { - categoryMap['newest-authors'].items.splice(indexToPut, 0, author) - } else { - categoryMap['newest-authors'].items.push(author) - } - - // Max authors is 10 - if (categoryMap['newest-authors'].items.length > 10) { - categoryMap['newest-authors'].items.pop() - categoryMap['newest-authors'].smallest = categoryMap['newest-authors'].items[categoryMap['newest-authors'].items.length - 1].addedAt - } - - categoryMap['newest-authors'].biggest = categoryMap['newest-authors'].items[0].addedAt - } - - authorMap[libraryAuthor.id] = author - } - } else { - authorMap[libraryAuthor.id].numBooks++ - } - } - } - - // Book listening and finished - if (mediaProgress) { - const categoryId = libraryItem.media.isEBookOnly ? 'read-again' : 'listen-again' - - // Handle most recently finished - if (mediaProgress.isFinished) { - if (mediaProgress.finishedAt > categoryMap[categoryId].smallest) { // Item belongs on shelf - const libraryItemObj = { - ...libraryItem.toJSONMinified(), - finishedAt: mediaProgress.finishedAt - } - - const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.finishedAt > i.finishedAt) - if (indexToPut >= 0) { - categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj) - } else { - categoryMap[categoryId].items.push(libraryItemObj) - } - if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap[categoryId].items.pop() - categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].finishedAt - } - categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].finishedAt - } - } else if (mediaProgress.inProgress && !mediaProgress.hideFromContinueListening) { // Handle most recently listened - const categoryId = libraryItem.media.isEBookOnly ? 'continue-reading' : 'continue-listening' - - if (mediaProgress.lastUpdate > categoryMap[categoryId].smallest) { // Item belongs on shelf - const libraryItemObj = { - ...libraryItem.toJSONMinified(), - progressLastUpdate: mediaProgress.lastUpdate - } - - const indexToPut = categoryMap[categoryId].items.findIndex(i => mediaProgress.lastUpdate > i.progressLastUpdate) - if (indexToPut >= 0) { - categoryMap[categoryId].items.splice(indexToPut, 0, libraryItemObj) - } else { // Should only happen when array is < max - categoryMap[categoryId].items.push(libraryItemObj) - } - if (categoryMap[categoryId].items.length > maxEntitiesPerShelf) { - // Remove last item - categoryMap[categoryId].items.pop() - categoryMap[categoryId].smallest = categoryMap[categoryId].items[categoryMap[categoryId].items.length - 1].progressLastUpdate - } - categoryMap[categoryId].biggest = categoryMap[categoryId].items[0].progressLastUpdate - } - } - } - } - } - - // For Continue Series - Find next book in series for series that are in progress - for (const seriesId in seriesMap) { - seriesMap[seriesId].books = naturalSort(seriesMap[seriesId].books).asc(li => li.seriesSequence) - - if (seriesMap[seriesId].inProgress && !seriesMap[seriesId].hideFromContinueListening) { - // take the first book unread with the smallest series sequence - // unless the user is already listening to a book from this series - const hasActiveBook = seriesMap[seriesId].hasActiveBook - const nextBookInSeries = seriesMap[seriesId].firstBookUnread - - if (!hasActiveBook && nextBookInSeries) { - const bookForContinueSeries = { - ...nextBookInSeries, - prevBookInProgressLastUpdate: seriesMap[seriesId].bookInProgressLastUpdate - } - bookForContinueSeries.media.metadata.series = { - id: seriesId, - name: seriesMap[seriesId].name, - sequence: nextBookInSeries.seriesSequence - } - - const indexToPut = categoryMap['continue-series'].items.findIndex(i => i.prevBookInProgressLastUpdate < bookForContinueSeries.prevBookInProgressLastUpdate) - if (!categoryMap['continue-series'].items.find(book => book.id === bookForContinueSeries.id)) { - if (indexToPut >= 0) { - categoryMap['continue-series'].items.splice(indexToPut, 0, bookForContinueSeries) - } else if (categoryMap['continue-series'].items.length < 10) { // Max 10 books - categoryMap['continue-series'].items.push(bookForContinueSeries) - } - } - } - } - } - - // For recommended - if (!isPodcastLibrary && notStartedBooks.length) { - const genresCount = Object.values(topGenresListened).reduce((a, b) => a + b, 0) - const authorsCount = Object.values(topAuthorsListened).reduce((a, b) => a + b, 0) - const tagsCount = Object.values(topTagsListened).reduce((a, b) => a + b, 0) - - for (const libraryItem of notStartedBooks) { - // dont include books in an unfinished series and books that are not first in an unstarted series - let shouldContinue = !libraryItem.media.metadata.series.length - libraryItem.media.metadata.series.forEach((se) => { - if (seriesMap[se.id]) { - if (seriesMap[se.id].inProgress) { - shouldContinue = false - return - } else if (seriesMap[se.id].books[0].id === libraryItem.id) { - shouldContinue = true - } - } - }) - if (!shouldContinue) { - continue; - } - - let totalWeight = 0 - - if (authorsCount > 0) { - libraryItem.media.metadata.authors.forEach((author) => { - if (topAuthorsListened[author.id]) { - totalWeight += topAuthorsListened[author.id] / authorsCount - } - }) - } - - if (genresCount > 0) { - libraryItem.media.metadata.genres.forEach((genre) => { - if (topGenresListened[genre]) { - totalWeight += topGenresListened[genre] / genresCount - } - }) - } - - if (tagsCount > 0) { - libraryItem.media.tags.forEach((tag) => { - if (topTagsListened[tag]) { - totalWeight += topTagsListened[tag] / tagsCount - } - }) - } - - if (!categoryMap.recommended.smallest || totalWeight > categoryMap.recommended.smallest) { - const libraryItemObj = { - ...libraryItem.toJSONMinified(), - weight: totalWeight - } - - const indexToPut = categoryMap.recommended.items.findIndex(i => totalWeight > i.weight) - if (indexToPut >= 0) { - categoryMap.recommended.items.splice(indexToPut, 0, libraryItemObj) - } else { - categoryMap.recommended.items.push(libraryItemObj) - } - - if (categoryMap.recommended.items.length > maxEntitiesPerShelf) { - categoryMap.recommended.items.pop() - categoryMap.recommended.smallest = categoryMap.recommended.items[categoryMap.recommended.items.length - 1].weight - } - } - } - } - - // Sort series books by sequence - if (categoryMap['recent-series'].items.length) { - if (hideSingleBookSeries) { - categoryMap['recent-series'].items = categoryMap['recent-series'].items.filter(seriesItem => seriesItem.books.length > 1) - } - // Limit series shown to 5 - categoryMap['recent-series'].items = categoryMap['recent-series'].items.slice(0, 5) - - for (const seriesItem of categoryMap['recent-series'].items) { - seriesItem.books = naturalSort(seriesItem.books).asc(li => li.seriesSequence) - } - } - - const categoriesWithItems = Object.values(categoryMap).filter(cat => cat.items.length) - - const finalShelves = [] - for (const categoryWithItems of categoriesWithItems) { - const shelf = shelves.find(s => s.id === categoryWithItems.id) - shelf.entities = categoryWithItems.items - - // Add rssFeed to entities if query string "include=rssfeed" was on request - if (includeRssFeed) { - if (shelf.type === 'book' || shelf.type === 'podcast') { - shelf.entities = await Promise.all(shelf.entities.map(async (item) => { - const feed = await ctx.rssFeedManager.findFeedForEntityId(item.id) - item.rssFeed = feed?.toJSONMinified() || null - return item - })) - } else if (shelf.type === 'series') { - shelf.entities = await Promise.all(shelf.entities.map(async (series) => { - const feed = await ctx.rssFeedManager.findFeedForEntityId(series.id) - series.rssFeed = feed?.toJSONMinified() || null - return series - })) - } - } - finalShelves.push(shelf) - } - return finalShelves - }, - - groupMusicLibraryItemsIntoAlbums(libraryItems) { - const albums = {} - - libraryItems.forEach((li) => { - const albumTitle = li.media.metadata.album - const albumArtist = li.media.metadata.albumArtist - - if (albumTitle && !albums[albumTitle]) { - albums[albumTitle] = { - title: albumTitle, - artist: albumArtist, - libraryItemId: li.media.coverPath ? li.id : null, - numTracks: 1 - } - } else if (albumTitle && albums[albumTitle].artist === albumArtist) { - if (!albums[albumTitle].libraryItemId && li.media.coverPath) albums[albumTitle].libraryItemId = li.id - albums[albumTitle].numTracks++ - } else { - if (albumTitle) { - Logger.warn(`Music track "${li.media.metadata.title}" with album "${albumTitle}" has a different album artist then another track in the same album. This track album artist is "${albumArtist}" but the album artist is already set to "${albums[albumTitle].artist}"`) - } - if (!albums['_none_']) albums['_none_'] = { title: 'No Album', artist: 'Various Artists', libraryItemId: null, numTracks: 0 } - albums['_none_'].numTracks++ - } - }) - - return Object.values(albums) } } diff --git a/server/utils/queries/authorFilters.js b/server/utils/queries/authorFilters.js index c7f85c4a..efaccd21 100644 --- a/server/utils/queries/authorFilters.js +++ b/server/utils/queries/authorFilters.js @@ -1,5 +1,4 @@ const Sequelize = require('sequelize') -const Logger = require('../../Logger') const Database = require('../../Database') module.exports = { diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index c2a30c18..ed0e9fba 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -15,8 +15,8 @@ module.exports = { /** * Get library items using filter and sort - * @param {oldLibrary} library - * @param {oldUser} user + * @param {import('../../objects/Library')} library + * @param {import('../../objects/user/User')} user * @param {object} options * @returns {object} { libraryItems:LibraryItem[], count:number } */ diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index 476d7708..24b23723 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -7,7 +7,7 @@ module.exports = { /** * Get all library items that have tags * @param {string[]} tags - * @returns {Promise} + * @returns {Promise} */ async getAllLibraryItemsWithTags(tags) { const libraryItems = []