Add remaining personalized shelf queries, update book libraries home page to use new API endpoint

This commit is contained in:
advplyr 2023-08-05 14:01:16 -05:00
parent 80b3bfea51
commit 09eefae808
20 changed files with 359 additions and 26 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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
}
}
}

View file

@ -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

View file

@ -147,7 +147,7 @@ module.exports = {
return libraryItem
})
Logger.debug('Found', libraryItems.length, 'library items', 'total=', count)
return {
libraryItems,
count