Add:Year in review card for server stats #2373

This commit is contained in:
advplyr 2023-12-22 17:01:07 -06:00
parent 68d36522b1
commit 2738402aac
8 changed files with 414 additions and 48 deletions

View File

@ -24,12 +24,15 @@ export default {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 400
canvas.height = 400
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
ctx.fillStyle = '#37383866'
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444466')
grd1.addColorStop(1, '#ffffff22')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C0aa'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
@ -72,8 +75,13 @@ export default {
if (this.yearStats.booksWithCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
for (let x = 0; x < 4; x++) {
for (let y = 0; y < 4; y++) {
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksWithCovers.length
let libraryItemId = this.yearStats.booksWithCovers[coverIndex]
index++
@ -82,7 +90,13 @@ export default {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
ctx.drawImage(img, 100 * x, 100 * y, 100, 100)
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
resolve()
})
img.addEventListener('error', () => {
@ -92,13 +106,14 @@ export default {
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, 400, 400)
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
@ -107,60 +122,60 @@ export default {
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '32px absicons'
ctx.fillText('\ue900', 15, 32)
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '22px', 'normal', tanColor, '0px', 55, 22)
addText(`${this.year} YEAR IN REVIEW`, '14px', 'bold', 'white', '1px', 55, 44)
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(10, 65, 185, 80)
addText(this.yearStats.numBooksFinished, '32px', 'bold', 'white', '0px', 63, 98)
addText('books finished', '14px', 'normal', tanColor, '0px', 63, 120)
createRoundedRect(50, 100, 340, 160)
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
const readIconPath = new Path2D()
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.2, d: 1.2, e: 26, f: 90 })
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
ctx.fillStyle = '#ffffff'
ctx.fill(readIconPath)
// Box top right
createRoundedRect(205, 65, 185, 80)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '20px', 'bold', 'white', '0px', 257, 96)
addText('spent listening', '14px', 'normal', tanColor, '0px', 257, 117)
addIcon('watch_later', 'white', '32px', 218, 105)
createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205)
addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left
createRoundedRect(10, 155, 185, 80)
addText(this.yearStats.totalListeningSessions, '32px', 'bold', 'white', '0px', 65, 188)
addText('sessions', '14px', 'normal', tanColor, '1px', 65, 210)
addIcon('headphones', 'white', '32px', 25, 195)
createRoundedRect(50, 280, 340, 160)
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
addIcon('headphones', 'white', '52px', 95, 360)
// Box bottom right
createRoundedRect(205, 155, 185, 80)
addText(this.yearStats.numBooksListened, '32px', 'bold', 'white', '0px', 258, 188)
addText('books listened to', '14px', 'normal', tanColor, '0.65px', 258, 210)
addIcon('local_library', 'white', '32px', 220, 195)
createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390)
addIcon('local_library', 'white', '52px', 440, 360)
// Text stats
const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) {
addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260)
addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282, 180)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302)
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
}
const topGenre = this.yearStats.topGenres[0]
if (topGenre) {
addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260)
addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282, 180)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302)
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
}
const topAuthor = this.yearStats.topAuthors[0]
if (topAuthor) {
addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335)
addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357, 180)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377)
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
}
this.dataUrl = canvas.toDataURL('png')
@ -173,7 +188,7 @@ export default {
let year = new Date().getFullYear()
if (new Date().getMonth() < 11) year--
this.year = year
this.yearStats = await this.$axios.$get(`/api/me/year/${year}/stats`).catch((err) => {
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null

View File

@ -0,0 +1,205 @@
<template>
<div>
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center">
<widgets-loading-spinner />
</div>
<img v-else-if="dataUrl" :src="dataUrl" />
</div>
</template>
<script>
export default {
props: {
processing: Boolean
},
data() {
return {
dataUrl: null,
year: null,
yearStats: null
}
},
methods: {
async initCanvas() {
if (!this.yearStats) return
const canvas = document.createElement('canvas')
canvas.width = 800
canvas.height = 800
const ctx = canvas.getContext('2d')
const createRoundedRect = (x, y, w, h) => {
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
grd1.addColorStop(0, '#44444466')
grd1.addColorStop(1, '#ffffff22')
ctx.fillStyle = grd1
ctx.strokeStyle = '#C0C0C0aa'
ctx.beginPath()
ctx.roundRect(x, y, w, h, [20])
ctx.fill()
ctx.stroke()
}
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
ctx.fillStyle = color
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
ctx.letterSpacing = letterSpacing
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
if (maxWidth) {
let txtWidth = ctx.measureText(text).width
while (txtWidth > maxWidth) {
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
else text = text.slice(0, -3) // First check remove last 3 chars
text += '...'
txtWidth = ctx.measureText(text).width
console.log(`Checking text "${text}" (width:${txtWidth})`)
}
}
ctx.fillText(text, x, y)
}
const addIcon = (icon, color, fontSize, x, y) => {
ctx.fillStyle = color
ctx.font = `${fontSize} Material Icons Outlined`
ctx.fillText(icon, x, y)
}
// Bg color
ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles
let imgsToAdd = {}
if (this.yearStats.booksAddedWithCovers.length) {
let index = 0
ctx.globalAlpha = 0.25
ctx.save()
ctx.translate(canvas.width / 2, canvas.height / 2)
ctx.rotate((-Math.PI / 180) * 25)
ctx.translate(-canvas.width / 2, -canvas.height / 2)
ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
index++
await new Promise((resolve) => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.addEventListener('load', () => {
let sw = img.width
if (img.width > img.height) {
sw = img.height
}
let sx = -(sw - img.width) / 2
let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
if (!imgsToAdd[libraryItemId]) {
imgsToAdd[libraryItemId] = {
img,
sx,
sy,
sw
}
}
resolve()
})
img.addEventListener('error', () => {
resolve()
})
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
})
}
}
ctx.restore()
}
ctx.globalAlpha = 1
ctx.textBaseline = 'middle'
// Create gradient
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
grd1.addColorStop(0, '#000000aa')
grd1.addColorStop(1, '#cd9d49aa')
ctx.fillStyle = grd1
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Top Abs icon
let tanColor = '#ffdb70'
ctx.fillStyle = tanColor
ctx.font = '42px absicons'
ctx.fillText('\ue900', 15, 36)
// Top text
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
// Top left box
createRoundedRect(40, 100, 230, 100)
ctx.textAlign = 'center'
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
// Box top right
createRoundedRect(285, 100, 230, 100)
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
// Box bottom left
createRoundedRect(530, 100, 230, 100)
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
// Text stats
if (this.yearStats.totalBooksAddedSize) {
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
}
if (this.yearStats.totalBooksAddedDuration) {
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
}
// Bottom images
imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length >= 5) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < 5; i++) {
let imgToAdd = imgsToAdd[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
}
}
this.dataUrl = canvas.toDataURL('png')
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
let year = new Date().getFullYear()
if (new Date().getMonth() < 11) year--
this.year = year
this.yearStats = await this.$axios.$get(`/api/stats/year/${year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -63,11 +63,13 @@
</div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
<ui-btn small :loading="processingYearInReview" @click.stop="clickShowYearInReview">Year in Review</ui-btn>
<ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}</ui-btn>
<div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" />
<stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" />
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" />
</div>
</app-settings-content>
</div>
@ -80,7 +82,8 @@ export default {
listeningStats: null,
windowWidth: 0,
showYearInReview: false,
processingYearInReview: false
processingYearInReview: false,
processingYearInReviewAlt: false
}
},
watch: {
@ -126,6 +129,10 @@ export default {
clickShowYearInReview() {
if (this.showYearInReview) {
this.$refs.yearInReview.refresh()
if (this.$refs.yearInReviewAlt) {
this.$refs.yearInReviewAlt.refresh()
}
} else {
this.showYearInReview = true
}

View File

@ -336,6 +336,7 @@ class MeController {
}
/**
* GET: /api/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
@ -346,7 +347,7 @@ class MeController {
Logger.error(`[MeController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
const data = await userStats.getStatsForYear(req.user.id, year)
const data = await userStats.getStatsForYear(req.user, year)
res.json(data)
}
}

View File

@ -11,6 +11,7 @@ const { isObject, getTitleIgnorePrefix } = require('../utils/index')
const { sanitizeFilename } = require('../utils/fileUtils')
const TaskManager = require('../managers/TaskManager')
const adminStats = require('../utils/queries/adminStats')
//
// This is a controller for routes that don't have a home yet :(
@ -696,5 +697,25 @@ class MiscController {
serverSettings: Database.serverSettings.toJSONForBrowser()
})
}
/**
* GET: /api/me/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getAdminStatsForYear(req, res) {
if (!req.user.isAdminOrUp) {
Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
return res.sendStatus(403)
}
const year = Number(req.params.year)
if (isNaN(year) || year < 2000 || year > 9999) {
Logger.error(`[MiscController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
const stats = await adminStats.getStatsForYear(year)
res.json(stats)
}
}
module.exports = new MiscController()

View File

@ -180,7 +180,7 @@ class ApiRouter {
this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
this.router.get('/me/year/:year/stats', MeController.getStatsForYear.bind(this))
this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
//
// Backup Routes
@ -317,6 +317,7 @@ class ApiRouter {
this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
}
async getDirectories(dir, relpath, excludedDirs, level = 0) {

View File

@ -0,0 +1,118 @@
const Sequelize = require('sequelize')
const Database = require('../../Database')
const PlaybackSession = require('../../models/PlaybackSession')
const fsExtra = require('../../libs/fsExtra')
module.exports = {
/**
*
* @param {number} year YYYY
* @returns {Promise<PlaybackSession[]>}
*/
async getListeningSessionsForYear(year) {
const sessions = await Database.playbackSessionModel.findAll({
where: {
createdAt: {
[Sequelize.Op.gte]: `${year}-01-01`,
[Sequelize.Op.lt]: `${year + 1}-01-01`
}
}
})
return sessions
},
/**
*
* @param {number} year YYYY
* @returns {Promise<number>}
*/
async getNumAuthorsAddedForYear(year) {
const count = await Database.authorModel.count({
where: {
createdAt: {
[Sequelize.Op.gte]: `${year}-01-01`,
[Sequelize.Op.lt]: `${year + 1}-01-01`
}
}
})
return count
},
/**
*
* @param {number} year YYYY
* @returns {Promise<import('../../models/Book')[]>}
*/
async getBooksAddedForYear(year) {
const books = await Database.bookModel.findAll({
attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'],
where: {
createdAt: {
[Sequelize.Op.gte]: `${year}-01-01`,
[Sequelize.Op.lt]: `${year + 1}-01-01`
}
},
include: {
model: Database.libraryItemModel,
attributes: ['id', 'mediaId', 'mediaType', 'size'],
required: true
},
order: Database.sequelize.random()
})
return books
},
/**
*
* @param {number} year YYYY
*/
async getStatsForYear(year) {
const booksAdded = await this.getBooksAddedForYear(year)
let totalBooksAddedSize = 0
let totalBooksAddedDuration = 0
const booksWithCovers = []
for (const book of booksAdded) {
// Grab first 25 that have a cover
if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
booksWithCovers.push(book.libraryItem.id)
}
if (book.duration && !isNaN(book.duration)) {
totalBooksAddedDuration += book.duration
}
if (book.libraryItem.size && !isNaN(book.libraryItem.size)) {
totalBooksAddedSize += book.libraryItem.size
}
}
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
const listeningSessions = await this.getListeningSessionsForYear(year)
let totalListeningTime = 0
for (const listeningSession of listeningSessions) {
totalListeningTime += (listeningSession.timeListening || 0)
}
// Stats for total books, size and duration for everything added this year or earlier
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
replacements: {
nextYear: year + 1
}
})
const totalStatResults = totalStatResultsRow[0]
return {
numListeningSessions: listeningSessions.length,
numBooksAdded: booksAdded.length,
numAuthorsAdded,
totalBooksAddedSize,
totalBooksAddedDuration: Math.round(totalBooksAddedDuration),
booksAddedWithCovers: booksWithCovers,
totalBooksSize: totalStatResults?.totalSize || 0,
totalBooksDuration: totalStatResults?.totalDuration || 0,
totalListeningTime,
numBooks: totalStatResults?.totalItems || 0
}
}
}

View File

@ -18,9 +18,6 @@ module.exports = {
createdAt: {
[Sequelize.Op.gte]: `${year}-01-01`,
[Sequelize.Op.lt]: `${year + 1}-01-01`
},
timeListening: {
[Sequelize.Op.gt]: 5
}
},
include: {
@ -66,10 +63,11 @@ module.exports = {
},
/**
* @param {string} userId
* @param {import('../../objects/user/User')} user
* @param {number} year YYYY
*/
async getStatsForYear(userId, year) {
async getStatsForYear(user, year) {
const userId = user.id
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
let totalBookListeningTime = 0
@ -84,8 +82,8 @@ module.exports = {
const booksWithCovers = []
for (const ls of listeningSessions) {
// Grab first 16 that have a cover
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
// Grab first 25 that have a cover
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
booksWithCovers.push(ls.mediaItem.libraryItem.id)
}