From 7391b4d0ece9830d6d8bd0e91a2d5a56fac72af7 Mon Sep 17 00:00:00 2001 From: advplyr Date: Tue, 19 Dec 2023 17:19:33 -0600 Subject: [PATCH] Add:User stats API for year stats --- server/Database.js | 7 +- server/controllers/MeController.js | 16 +++ server/routers/ApiRouter.js | 1 + server/utils/queries/userStats.js | 178 +++++++++++++++++++++++++++++ 4 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 server/utils/queries/userStats.js diff --git a/server/Database.js b/server/Database.js index 5721ac27..8a357481 100644 --- a/server/Database.js +++ b/server/Database.js @@ -122,11 +122,16 @@ class Database { return this.models.feed } - /** @type {typeof import('./models/Feed')} */ + /** @type {typeof import('./models/FeedEpisode')} */ get feedEpisodeModel() { return this.models.feedEpisode } + /** @type {typeof import('./models/PlaybackSession')} */ + get playbackSessionModel() { + return this.models.playbackSession + } + /** * Check if db file exists * @returns {boolean} diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index d38c1c4a..42387b59 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -3,6 +3,7 @@ const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const { sort } = require('../libs/fastSort') const { toNumber } = require('../utils/index') +const userStats = require('../utils/queries/userStats') class MeController { constructor() { } @@ -333,5 +334,20 @@ class MeController { } res.json(req.user.toJSONForBrowser()) } + + /** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + */ + async getStatsForYear(req, res) { + const year = Number(req.params.year) + if (isNaN(year) || year < 2000 || year > 9999) { + Logger.error(`[MeController] Invalid year "${year}"`) + return res.status(400).send('Invalid year') + } + const data = await userStats.getStatsForYear(req.user.id, year) + res.json(data) + } } module.exports = new MeController() \ No newline at end of file diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index d7714568..f2418180 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -180,6 +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)) // // Backup Routes diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js new file mode 100644 index 00000000..b6895008 --- /dev/null +++ b/server/utils/queries/userStats.js @@ -0,0 +1,178 @@ +const Sequelize = require('sequelize') +const Database = require('../../Database') +const PlaybackSession = require('../../models/PlaybackSession') +const MediaProgress = require('../../models/MediaProgress') +const { elapsedPretty } = require('../index') + +module.exports = { + /** + * + * @param {string} userId + * @param {number} year YYYY + * @returns {Promise} + */ + async getUserListeningSessionsForYear(userId, year) { + const sessions = await Database.playbackSessionModel.findAll({ + where: { + userId, + createdAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + } + }) + return sessions + }, + + /** + * + * @param {string} userId + * @param {number} year YYYY + * @returns {Promise} + */ + async getBookMediaProgressFinishedForYear(userId, year) { + const progresses = await Database.mediaProgressModel.findAll({ + where: { + userId, + mediaItemType: 'book', + finishedAt: { + [Sequelize.Op.gte]: `${year}-01-01`, + [Sequelize.Op.lt]: `${year + 1}-01-01` + } + }, + include: { + model: Database.bookModel, + required: true + } + }) + return progresses + }, + + /** + * @param {string} userId + * @param {number} year YYYY + */ + async getStatsForYear(userId, year) { + const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) + + let totalBookListeningTime = 0 + let totalPodcastListeningTime = 0 + let totalListeningTime = 0 + + let authorListeningMap = {} + let genreListeningMap = {} + let narratorListeningMap = {} + let monthListeningMap = {} + + listeningSessions.forEach((ls) => { + const listeningSessionListeningTime = ls.timeListening || 0 + + const lsMonth = ls.createdAt.getMonth() + if (!monthListeningMap[lsMonth]) monthListeningMap[lsMonth] = 0 + monthListeningMap[lsMonth] += listeningSessionListeningTime + + totalListeningTime += listeningSessionListeningTime + if (ls.mediaItemType === 'book') { + totalBookListeningTime += listeningSessionListeningTime + + const authors = ls.mediaMetadata.authors || [] + authors.forEach((au) => { + if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0 + authorListeningMap[au.name] += listeningSessionListeningTime + }) + + const narrators = ls.mediaMetadata.narrators || [] + narrators.forEach((narrator) => { + if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0 + narratorListeningMap[narrator] += listeningSessionListeningTime + }) + + // Filter out bad genres like "audiobook" and "audio book" + const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + genres.forEach((genre) => { + if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 + genreListeningMap[genre] += listeningSessionListeningTime + }) + } else { + totalPodcastListeningTime += listeningSessionListeningTime + } + }) + + totalListeningTime = Math.round(totalListeningTime) + totalBookListeningTime = Math.round(totalBookListeningTime) + totalPodcastListeningTime = Math.round(totalPodcastListeningTime) + + let mostListenedAuthor = null + for (const authorName in authorListeningMap) { + if (!mostListenedAuthor?.time || authorListeningMap[authorName] > mostListenedAuthor.time) { + mostListenedAuthor = { + time: Math.round(authorListeningMap[authorName]), + pretty: elapsedPretty(Math.round(authorListeningMap[authorName])), + name: authorName + } + } + } + let mostListenedNarrator = null + for (const narrator in narratorListeningMap) { + if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) { + mostListenedNarrator = { + time: Math.round(narratorListeningMap[narrator]), + pretty: elapsedPretty(Math.round(narratorListeningMap[narrator])), + name: narrator + } + } + } + let mostListenedGenre = null + for (const genre in genreListeningMap) { + if (!mostListenedGenre?.time || genreListeningMap[genre] > mostListenedGenre.time) { + mostListenedGenre = { + time: Math.round(genreListeningMap[genre]), + pretty: elapsedPretty(Math.round(genreListeningMap[genre])), + name: genre + } + } + } + let mostListenedMonth = null + for (const month in monthListeningMap) { + if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) { + mostListenedMonth = { + month: Number(month), + time: Math.round(monthListeningMap[month]), + pretty: elapsedPretty(Math.round(monthListeningMap[month])) + } + } + } + + const bookProgresses = await this.getBookMediaProgressFinishedForYear(userId, year) + + const numBooksFinished = bookProgresses.length + let longestAudiobookFinished = null + bookProgresses.forEach((mediaProgress) => { + if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) { + longestAudiobookFinished = { + id: mediaProgress.mediaItem.id, + title: mediaProgress.mediaItem.title, + duration: Math.round(mediaProgress.duration), + durationPretty: elapsedPretty(Math.round(mediaProgress.duration)), + finishedAt: mediaProgress.finishedAt + } + } + }) + + return { + totalListeningSessions: listeningSessions.length, + totalListeningTime, + totalListeningTimePretty: elapsedPretty(totalListeningTime), + totalBookListeningTime, + totalBookListeningTimePretty: elapsedPretty(totalBookListeningTime), + totalPodcastListeningTime, + totalPodcastListeningTimePretty: elapsedPretty(totalPodcastListeningTime), + mostListenedAuthor, + mostListenedNarrator, + mostListenedGenre, + mostListenedMonth, + numBooksFinished, + longestAudiobookFinished + } + } +}