From 09eefae808ac25f13ca9ab71916bb61b0304a222 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 5 Aug 2023 14:01:16 -0500 Subject: [PATCH] Add remaining personalized shelf queries, update book libraries home page to use new API endpoint --- .../components/app/BookShelfCategorized.vue | 8 +- .../controls/LibraryFilterSelect.vue | 4 + client/strings/de.json | 4 +- client/strings/en-us.json | 2 + client/strings/es.json | 2 + client/strings/fr.json | 2 + client/strings/gu.json | 2 + client/strings/hi.json | 2 + client/strings/hr.json | 2 + client/strings/it.json | 4 +- client/strings/lt.json | 4 +- client/strings/nl.json | 2 + client/strings/pl.json | 2 + client/strings/ru.json | 2 + client/strings/zh-cn.json | 2 + server/models/LibraryItem.js | 85 +++++++++- server/utils/libraryHelpers.js | 3 +- server/utils/queries/libraryFilters.js | 106 ++++++++++++- .../utils/queries/libraryItemsBookFilters.js | 145 +++++++++++++++++- .../queries/libraryItemsPodcastFilters.js | 2 +- 20 files changed, 359 insertions(+), 26 deletions(-) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index b0d19df5..802b7b12 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -68,6 +68,9 @@ export default { currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, + currentLibraryMediaType() { + return this.$store.getters['libraries/getCurrentLibraryMediaType'] + }, libraryName() { return this.$store.getters['libraries/getCurrentLibraryName'] }, @@ -167,8 +170,9 @@ export default { this.loaded = true }, async fetchCategories() { + const endpoint = this.currentLibraryMediaType === 'book' ? 'personalized2' : 'personalized' const categories = await this.$axios - .$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`) + .$get(`/api/libraries/${this.currentLibraryId}/${endpoint}?include=rssfeed,numEpisodesIncomplete`) .then((data) => { return data }) @@ -346,8 +350,6 @@ export default { }) }, episodeAdded(episodeWithLibraryItem) { - console.log('Podcast episode added', episodeWithLibraryItem) - const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId if (!this.search && isThisLibrary) { this.fetchCategories() diff --git a/client/components/controls/LibraryFilterSelect.vue b/client/components/controls/LibraryFilterSelect.vue index e3393dad..5ed4400b 100644 --- a/client/components/controls/LibraryFilterSelect.vue +++ b/client/components/controls/LibraryFilterSelect.vue @@ -348,6 +348,10 @@ export default { }, tracks() { return [ + { + id: 'none', + name: this.$strings.LabelTracksNone + }, { id: 'single', name: this.$strings.LabelTracksSingleTrack diff --git a/client/strings/de.json b/client/strings/de.json index fad732d1..f6edab6d 100644 --- a/client/strings/de.json +++ b/client/strings/de.json @@ -222,6 +222,7 @@ "LabelDirectory": "Verzeichnis", "LabelDiscFromFilename": "CD aus dem Dateinamen", "LabelDiscFromMetadata": "CD aus den Metadaten", + "LabelDiscover": "Discover", "LabelDownload": "Herunterladen", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Laufzeit", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Titel aus Metadaten", "LabelTracks": "Dateien", "LabelTracksMultiTrack": "Mehrfachdatei", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Einzeldatei", "LabelType": "Typ", "LabelUnabridged": "Ungekürzt", @@ -702,4 +704,4 @@ "ToastSocketFailedToConnect": "Verbindung zum WebSocket fehlgeschlagen", "ToastUserDeleteFailed": "Benutzer konnte nicht gelöscht werden", "ToastUserDeleteSuccess": "Benutzer gelöscht" -} +} \ No newline at end of file diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 65a753c7..3937e8c5 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -222,6 +222,7 @@ "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", + "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Duration", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Track from Metadata", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Type", "LabelUnabridged": "Unabridged", diff --git a/client/strings/es.json b/client/strings/es.json index 363ef4a6..5be1e3b5 100644 --- a/client/strings/es.json +++ b/client/strings/es.json @@ -222,6 +222,7 @@ "LabelDirectory": "Directorio", "LabelDiscFromFilename": "Disco a partir del Nombre del Archivo", "LabelDiscFromMetadata": "Disco a partir de Metadata", + "LabelDiscover": "Discover", "LabelDownload": "Descargar", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Duración", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Pista desde Metadata", "LabelTracks": "Pistas", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Tipo", "LabelUnabridged": "Unabridged", diff --git a/client/strings/fr.json b/client/strings/fr.json index 56c0e72d..ea98d08e 100644 --- a/client/strings/fr.json +++ b/client/strings/fr.json @@ -222,6 +222,7 @@ "LabelDirectory": "Répertoire", "LabelDiscFromFilename": "Disque depuis le fichier", "LabelDiscFromMetadata": "Disque depuis les métadonnées", + "LabelDiscover": "Discover", "LabelDownload": "Téléchargement", "LabelDownloadNEpisodes": "Télécharger {0} épisode(s)", "LabelDuration": "Durée", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Piste depuis les métadonnées", "LabelTracks": "Pistes", "LabelTracksMultiTrack": "Piste multiple", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Piste simple", "LabelType": "Type", "LabelUnabridged": "Version intégrale", diff --git a/client/strings/gu.json b/client/strings/gu.json index 04915ef7..9c6fd369 100644 --- a/client/strings/gu.json +++ b/client/strings/gu.json @@ -222,6 +222,7 @@ "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", + "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Duration", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Track from Metadata", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Type", "LabelUnabridged": "Unabridged", diff --git a/client/strings/hi.json b/client/strings/hi.json index d0bb3435..96d551f4 100644 --- a/client/strings/hi.json +++ b/client/strings/hi.json @@ -222,6 +222,7 @@ "LabelDirectory": "Directory", "LabelDiscFromFilename": "Disc from Filename", "LabelDiscFromMetadata": "Disc from Metadata", + "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Duration", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Track from Metadata", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Type", "LabelUnabridged": "Unabridged", diff --git a/client/strings/hr.json b/client/strings/hr.json index 1f3e9ecd..f36d62c4 100644 --- a/client/strings/hr.json +++ b/client/strings/hr.json @@ -222,6 +222,7 @@ "LabelDirectory": "Direktorij", "LabelDiscFromFilename": "CD iz imena datoteke", "LabelDiscFromMetadata": "CD iz metapodataka", + "LabelDiscover": "Discover", "LabelDownload": "Preuzmi", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Trajanje", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Track iz metapodataka", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Tip", "LabelUnabridged": "Unabridged", diff --git a/client/strings/it.json b/client/strings/it.json index d5a2bb8a..68912781 100644 --- a/client/strings/it.json +++ b/client/strings/it.json @@ -222,6 +222,7 @@ "LabelDirectory": "Elenco", "LabelDiscFromFilename": "Disco dal nome file", "LabelDiscFromMetadata": "Disco dal Metadata", + "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Durata", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Traccia da Metadata", "LabelTracks": "Traccia", "LabelTracksMultiTrack": "Multi-traccia", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Traccia-singola", "LabelType": "Tipo", "LabelUnabridged": "Integrale", @@ -702,4 +704,4 @@ "ToastSocketFailedToConnect": "Socket non riesce a connettersi", "ToastUserDeleteFailed": "Errore eliminazione utente", "ToastUserDeleteSuccess": "Utente eliminato" -} +} \ No newline at end of file diff --git a/client/strings/lt.json b/client/strings/lt.json index fcd902cd..0c4cda1f 100644 --- a/client/strings/lt.json +++ b/client/strings/lt.json @@ -222,6 +222,7 @@ "LabelDirectory": "Katalogas", "LabelDiscFromFilename": "Diskas pagal failo pavadinimą", "LabelDiscFromMetadata": "Diskas pagal metaduomenis", + "LabelDiscover": "Discover", "LabelDownload": "Atsisiųsti", "LabelDownloadNEpisodes": "Atsisiųsti {0} epizodų", "LabelDuration": "Trukmė", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Takelis iš metaduomenų", "LabelTracks": "Takeliai", "LabelTracksMultiTrack": "Keli takeliai", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Vienas takelis", "LabelType": "Tipas", "LabelUnabridged": "Neprikurptas", @@ -702,4 +704,4 @@ "ToastSocketFailedToConnect": "Nepavyko prisijungti prie serverio", "ToastUserDeleteFailed": "Nepavyko ištrinti naudotojo", "ToastUserDeleteSuccess": "Naudotojas ištrintas" -} +} \ No newline at end of file diff --git a/client/strings/nl.json b/client/strings/nl.json index dd23773c..62b3be68 100644 --- a/client/strings/nl.json +++ b/client/strings/nl.json @@ -222,6 +222,7 @@ "LabelDirectory": "Map", "LabelDiscFromFilename": "Schijf uit bestandsnaam", "LabelDiscFromMetadata": "Schijf uit metadata", + "LabelDiscover": "Discover", "LabelDownload": "Download", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Duur", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Track vanuit metadata", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Type", "LabelUnabridged": "Onverkort", diff --git a/client/strings/pl.json b/client/strings/pl.json index 1b7e40e7..c55e7360 100644 --- a/client/strings/pl.json +++ b/client/strings/pl.json @@ -222,6 +222,7 @@ "LabelDirectory": "Katalog", "LabelDiscFromFilename": "Oznaczenie dysku z nazwy pliku", "LabelDiscFromMetadata": "Oznaczenie dysku z metadanych", + "LabelDiscover": "Discover", "LabelDownload": "Pobierz", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Czas trwania", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Ścieżka z metadanych", "LabelTracks": "Tracks", "LabelTracksMultiTrack": "Multi-track", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Single-track", "LabelType": "Typ", "LabelUnabridged": "Unabridged", diff --git a/client/strings/ru.json b/client/strings/ru.json index bf02f1e2..ab986e85 100644 --- a/client/strings/ru.json +++ b/client/strings/ru.json @@ -222,6 +222,7 @@ "LabelDirectory": "Каталог", "LabelDiscFromFilename": "Диск из Имени файла", "LabelDiscFromMetadata": "Диск из Метаданных", + "LabelDiscover": "Discover", "LabelDownload": "Скачать", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "Длина", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "Трек из Метаданных", "LabelTracks": "Треков", "LabelTracksMultiTrack": "Мультитрек", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "Один трек", "LabelType": "Тип", "LabelUnabridged": "Полное издание", diff --git a/client/strings/zh-cn.json b/client/strings/zh-cn.json index db40c2b8..de11b591 100644 --- a/client/strings/zh-cn.json +++ b/client/strings/zh-cn.json @@ -222,6 +222,7 @@ "LabelDirectory": "目录", "LabelDiscFromFilename": "从文件名获取光盘", "LabelDiscFromMetadata": "从元数据获取光盘", + "LabelDiscover": "Discover", "LabelDownload": "下载", "LabelDownloadNEpisodes": "Download {0} episodes", "LabelDuration": "持续时间", @@ -474,6 +475,7 @@ "LabelTrackFromMetadata": "从源数据获取音轨", "LabelTracks": "音轨", "LabelTracksMultiTrack": "多轨", + "LabelTracksNone": "No tracks", "LabelTracksSingleTrack": "单轨", "LabelType": "类型", "LabelUnabridged": "未删节", diff --git a/server/models/LibraryItem.js b/server/models/LibraryItem.js index e031c177..e01b779f 100644 --- a/server/models/LibraryItem.js +++ b/server/models/LibraryItem.js @@ -1,4 +1,4 @@ -const { DataTypes, Model, literal } = require('sequelize') +const { DataTypes, Model } = require('sequelize') const Logger = require('../Logger') const oldLibraryItem = require('../objects/LibraryItem') const libraryFilters = require('../utils/queries/libraryFilters') @@ -447,32 +447,40 @@ module.exports = (sequelize) => { */ static async getPersonalizedShelves(library, userId, include, limit) { const isPodcastLibrary = library.mediaType === 'podcast' + + const fullStart = Date.now() // Used for testing load times + const shelves = [] - const itemsInProgressPayload = await libraryFilters.getLibraryItemsInProgress(library, userId, include, limit, false) - if (itemsInProgressPayload.libraryItems.length) { + + const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, userId, include, limit, false) + if (itemsInProgressPayload.items.length) { shelves.push({ id: 'continue-listening', label: 'Continue Listening', labelStringKey: 'LabelContinueListening', type: isPodcastLibrary ? 'episode' : 'book', - entities: itemsInProgressPayload.libraryItems, + entities: itemsInProgressPayload.items, total: itemsInProgressPayload.count }) } + Logger.debug(`Loaded ${itemsInProgressPayload.items.length} items for "Continue Listening" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) + let start = Date.now() if (library.mediaType === 'book') { - const ebooksInProgressPayload = await libraryFilters.getLibraryItemsInProgress(library, userId, include, limit, true) - if (ebooksInProgressPayload.libraryItems.length) { + const ebooksInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, userId, include, limit, true) + if (ebooksInProgressPayload.items.length) { shelves.push({ id: 'continue-reading', label: 'Continue Reading', labelStringKey: 'LabelContinueReading', type: 'book', - entities: ebooksInProgressPayload.libraryItems, + entities: ebooksInProgressPayload.items, total: ebooksInProgressPayload.count }) } + Logger.debug(`Loaded ${ebooksInProgressPayload.items.length} items for "Continue Reading" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + start = Date.now() const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, userId, include, limit) if (continueSeriesPayload.libraryItems.length) { shelves.push({ @@ -484,6 +492,8 @@ module.exports = (sequelize) => { total: continueSeriesPayload.count }) } + Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + start = Date.now() } const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, userId, include, limit) @@ -497,7 +507,9 @@ module.exports = (sequelize) => { total: mostRecentPayload.count }) } + Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + start = Date.now() const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, include, 5) if (seriesMostRecentPayload.series.length) { shelves.push({ @@ -509,6 +521,65 @@ module.exports = (sequelize) => { total: seriesMostRecentPayload.count }) } + Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} items for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, userId, include, limit) + if (discoverLibraryItemsPayload.libraryItems.length) { + shelves.push({ + id: 'discover', + label: 'Discover', + labelStringKey: 'LabelDiscover', + type: library.mediaType, + entities: discoverLibraryItemsPayload.libraryItems, + total: discoverLibraryItemsPayload.count + }) + } + Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + const listenAgainPayload = await libraryFilters.getMediaFinished(library, userId, include, limit, false) + if (listenAgainPayload.items.length) { + shelves.push({ + id: 'listen-again', + label: 'Listen Again', + labelStringKey: 'LabelListenAgain', + type: isPodcastLibrary ? 'episode' : 'book', + entities: listenAgainPayload.items, + total: listenAgainPayload.count + }) + } + Logger.debug(`Loaded ${listenAgainPayload.items.length} items for "Listen Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + const readAgainPayload = await libraryFilters.getMediaFinished(library, userId, include, limit, true) + if (readAgainPayload.items.length) { + shelves.push({ + id: 'read-again', + label: 'Read Again', + labelStringKey: 'LabelReadAgain', + type: 'book', + entities: readAgainPayload.items, + total: readAgainPayload.count + }) + } + Logger.debug(`Loaded ${readAgainPayload.items.length} items for "Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + start = Date.now() + const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, limit) + if (newestAuthorsPayload.authors.length) { + shelves.push({ + id: 'newest-authors', + label: 'Newest Authors', + labelStringKey: 'LabelNewestAuthors', + type: 'authors', + entities: newestAuthorsPayload.authors, + total: newestAuthorsPayload.count + }) + } + Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`) + + Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`) return shelves } diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 931682d8..20806a7a 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -62,7 +62,8 @@ module.exports = { } else if (group === 'languages') { filtered = filtered.filter(li => li.media.metadata.language === filter) } else if (group === 'tracks') { - if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1) + if (filter === 'none') filtered = filtered.filter(li => li.isBook && !li.media.numTracks) + else if (filter === 'single') filtered = filtered.filter(li => li.isBook && li.media.numTracks === 1) else if (filter === 'multi') filtered = filtered.filter(li => li.isBook && li.media.numTracks > 1) } else if (group === 'ebooks') { if (filter === 'ebook') filtered = filtered.filter(li => li.media.ebookFile) diff --git a/server/utils/queries/libraryFilters.js b/server/utils/queries/libraryFilters.js index 9712e55e..26381c99 100644 --- a/server/utils/queries/libraryFilters.js +++ b/server/utils/queries/libraryFilters.js @@ -1,3 +1,4 @@ +const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') const libraryItemsBookFilters = require('./libraryItemsBookFilters') @@ -41,14 +42,14 @@ module.exports = { * @param {string[]} include * @param {number} limit * @param {boolean} ebook true if continue reading shelf - * @returns {object} { libraryItems:LibraryItem[], count:number } + * @returns {object} { items:LibraryItem[], count:number } */ - async getLibraryItemsInProgress(library, userId, include, limit, ebook = false) { + async getMediaItemsInProgress(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 => { + items: libraryItems.map(li => { const oldLibraryItem = Database.models.libraryItem.getOldLibraryItem(li).toJSONMinified() if (li.rssFeed) { oldLibraryItem.rssFeed = Database.models.feed.getOldFeed(li.rssFeed).toJSONMinified() @@ -58,9 +59,10 @@ module.exports = { count } } else { + // TODO: Get episodes in progress return { count: 0, - libraryItems: [] + items: [] } } }, @@ -132,6 +134,40 @@ module.exports = { } }, + /** + * Get library items or podcast episodes for the "Listen Again" or "Read Again" shelf + * @param {oldLibrary} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @param {boolean} ebook true if "Read Again" shelf + * @returns {object} { items:object[], count:number } + */ + async getMediaFinished(library, userId, include, limit, ebook = false) { + if (ebook && library.mediaType !== 'book') return { items: [], count: 0 } + + if (library.mediaType === 'book') { + const filterValue = ebook ? 'ebook-finished' : 'finished' + const { libraryItems, count } = await libraryItemsBookFilters.getFilteredLibraryItems(library.id, userId, 'progress', filterValue, 'progress', true, false, include, limit, 0) + return { + items: 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 { + // TODO: Get podcast episodes finished + return { + items: [], + count: 0 + } + } + }, + /** * Get series for recent series shelf * @param {oldLibrary} library @@ -140,6 +176,8 @@ module.exports = { * @returns {object} { series:oldSeries[], count:number} */ async getSeriesMostRecentlyAdded(library, include, limit) { + if (library.mediaType !== 'book') return { series: [], count: 0 } + const seriesIncludes = [] if (include.includes('rssfeed')) { seriesIncludes.push({ @@ -172,8 +210,6 @@ module.exports = { ] }) - Logger.debug(`Found ${series.length} series recently added (${count} total)`) - const allOldSeries = [] for (const s of series) { const oldSeries = s.getOldSeries().toJSON() @@ -205,5 +241,63 @@ module.exports = { series: allOldSeries, count } + }, + + /** + * Get most recently created authors for "Newest Authors" shelf + * Author must be linked to at least 1 book + * @param {oldLibrary} library + * @param {number} limit + * @returns {object} { authors:oldAuthor[], count:number } + */ + async getNewestAuthors(library, limit) { + if (library.mediaType !== 'book') return { authors: [], count: 0 } + + const { rows: authors, count } = await Database.models.author.findAndCountAll({ + where: { + libraryId: library.id + }, + include: { + model: Database.models.bookAuthor, + required: true // Must belong to a book + }, + limit, + distinct: true, + order: [ + ['createdAt', 'DESC'] + ] + }) + + return { + authors: authors.map((au) => { + const numBooks = au.bookAuthors?.length || 0 + return au.getOldAuthor().toJSONExpanded(numBooks) + }), + count + } + }, + + /** + * Get book library items for the "Discover" shelf + * @param {oldLibrary} library + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @returns {object} {libraryItems:oldLibraryItem[], count:number} + */ + async getLibraryItemsToDiscover(library, userId, include, limit) { + if (library.mediaType !== 'book') return { libraryItems: [], count: 0 } + + const { libraryItems, count } = await libraryItemsBookFilters.getDiscoverLibraryItems(library.id, userId, include, limit) + 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 + } } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 935bd86a..3c0d345e 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -119,7 +119,9 @@ module.exports = { } ] } else if (value === 'ebook-in-progress') { - mediaWhere[Sequelize.Op.and] = [ + // Filters for ebook only + mediaWhere = [ + Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0), { '$mediaProgresses.ebookProgress$': { [Sequelize.Op.gt]: 0 @@ -129,6 +131,17 @@ module.exports = { '$mediaProgresses.isFinished$': false } ] + } else if (value === 'ebook-finished') { + // Filters for ebook only + mediaWhere = [ + Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0), + { + '$mediaProgresses.isFinished$': true, + 'ebookFile': { + [Sequelize.Op.not]: null + } + } + ] } } else if (group === 'series' && value === 'no-series') { mediaWhere['$series.id$'] = null @@ -144,7 +157,9 @@ module.exports = { } else if (group === 'languages') { mediaWhere['language'] = value } else if (group === 'tracks') { - if (value === 'multi') { + if (value === 'none') { + mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), 0) + } else if (value === 'multi') { mediaWhere = Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('audioFiles')), { [Sequelize.Op.gt]: 1 }) @@ -542,7 +557,7 @@ module.exports = { return libraryItem }) - Logger.debug('Found', libraryItems.length, 'library items', 'total=', count) + return { libraryItems, count @@ -663,8 +678,6 @@ module.exports = { offset }) - 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 => { // Natural sort sequence, nulls last @@ -695,6 +708,128 @@ module.exports = { libraryItem.media = bookSeries.book return libraryItem }) + return { + libraryItems, + count + } + }, + + /** + * Get book library items for the "Discover" shelf + * Random selection of books that are not started + * - only includes the first book of a not-started series + * @param {string} libraryId + * @param {string} userId + * @param {string[]} include + * @param {number} limit + * @returns {object} {libraryItems:LibraryItem, count:number} + */ + async getDiscoverLibraryItems(libraryId, userId, include, limit) { + // Step 1: Get the first book of every series that hasnt been started yet + const seriesNotStarted = await Database.models.series.findAll({ + where: [ + { + libraryId + }, + Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM bookSeries bs LEFT OUTER JOIN mediaProgresses mp ON mp.mediaItemId = bs.bookId WHERE bs.seriesId = series.id AND mp.userId = :userId AND (mp.isFinished = 1 OR mp.currentTime > 0))`), 0) + ], + replacements: { + userId + }, + attributes: ['id'], + include: { + model: Database.models.bookSeries, + attributes: ['bookId', 'sequence'], + separate: true, + required: true, + order: [ + [Sequelize.literal('CAST(sequence AS INTEGER) ASC NULLS LAST')] + ], + limit: 1 + }, + subQuery: false + }) + + const booksFromSeriesToInclude = seriesNotStarted.map(se => se.bookSeries?.[0]?.bookId).filter(bid => bid) + + // optional include rssFeed + const libraryItemIncludes = [] + if (include.includes('rssfeed')) { + libraryItemIncludes.push({ + model: Database.models.feed + }) + } + + // Step 2: Get books not started and not in a series OR is the first book of a series not started (ordered randomly) + const { rows: books, count } = await Database.models.book.findAndCountAll({ + where: { + '$mediaProgresses.isFinished$': { + [Sequelize.Op.or]: [null, 0] + }, + '$mediaProgresses.currentTime$': { + [Sequelize.Op.or]: [null, 0] + }, + [Sequelize.Op.or]: [ + Sequelize.where(Sequelize.literal(`(SELECT COUNT(*) FROM bookSeries bs where bs.bookId = book.id)`), 0), + { + id: { + [Sequelize.Op.in]: booksFromSeriesToInclude + } + } + ] + }, + include: [ + { + model: Database.models.libraryItem, + where: { + libraryId + }, + include: libraryItemIncludes + }, + { + model: Database.models.mediaProgress, + where: { + userId + }, + required: false + }, + { + model: Database.models.bookAuthor, + attributes: ['authorId'], + include: { + model: Database.models.author + }, + separate: true + }, + { + model: Database.models.bookSeries, + attributes: ['seriesId', 'sequence'], + include: { + model: Database.models.series + }, + separate: true + } + ], + subQuery: false, + distinct: true, + limit, + order: Database.sequelize.random() + }) + + // Step 3: Map books to library items + const libraryItems = books.map((bookExpanded) => { + const libraryItem = bookExpanded.libraryItem.toJSON() + const book = bookExpanded.toJSON() + delete book.libraryItem + libraryItem.media = book + + if (libraryItem.feeds?.length) { + libraryItem.rssFeed = libraryItem.feeds[0] + } + + return libraryItem + }) + return { libraryItems, count diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index e71d9740..1fea46c3 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -147,7 +147,7 @@ module.exports = { return libraryItem }) - Logger.debug('Found', libraryItems.length, 'library items', 'total=', count) + return { libraryItems, count