From 2738402aacbe43f21061667b417da35ceb8d5c10 Mon Sep 17 00:00:00 2001 From: advplyr Date: Fri, 22 Dec 2023 17:01:07 -0600 Subject: [PATCH] Add:Year in review card for server stats #2373 --- client/components/stats/YearInReview.vue | 89 ++++---- .../components/stats/YearInReviewServer.vue | 205 ++++++++++++++++++ client/pages/config/stats.vue | 11 +- server/controllers/MeController.js | 3 +- server/controllers/MiscController.js | 21 ++ server/routers/ApiRouter.js | 3 +- server/utils/queries/adminStats.js | 118 ++++++++++ server/utils/queries/userStats.js | 12 +- 8 files changed, 414 insertions(+), 48 deletions(-) create mode 100644 client/components/stats/YearInReviewServer.vue create mode 100644 server/utils/queries/adminStats.js diff --git a/client/components/stats/YearInReview.vue b/client/components/stats/YearInReview.vue index 74c57065a4..104392b4e6 100644 --- a/client/components/stats/YearInReview.vue +++ b/client/components/stats/YearInReview.vue @@ -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 diff --git a/client/components/stats/YearInReviewServer.vue b/client/components/stats/YearInReviewServer.vue new file mode 100644 index 0000000000..0d1fa8aa25 --- /dev/null +++ b/client/components/stats/YearInReviewServer.vue @@ -0,0 +1,205 @@ + + + \ No newline at end of file diff --git a/client/pages/config/stats.vue b/client/pages/config/stats.vue index b527ea38a8..96581714da 100644 --- a/client/pages/config/stats.vue +++ b/client/pages/config/stats.vue @@ -63,11 +63,13 @@ - Year in Review + {{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}
+ +
@@ -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 } diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 42387b5968..8fa5c6bc35 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -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) } } diff --git a/server/controllers/MiscController.js b/server/controllers/MiscController.js index db4110e099..c2272ee66b 100644 --- a/server/controllers/MiscController.js +++ b/server/controllers/MiscController.js @@ -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() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 42e1d0401a..3edce256f4 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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) { diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js new file mode 100644 index 0000000000..66a31e8de7 --- /dev/null +++ b/server/utils/queries/adminStats.js @@ -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} + */ + 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} + */ + 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} + */ + 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 + } + } +} diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index f9b9684ef8..0f99778948 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -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) }