From 4fb53303087659065ae0bacf2cac5714dd62ad72 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 29 Mar 2025 17:34:17 -0500 Subject: [PATCH 1/2] Create new StatsController and move year in review stats endpoint --- server/controllers/StatsController.js | 75 +++++++++++++++++++++++++++ server/routers/ApiRouter.js | 10 ++-- server/utils/queries/adminStats.js | 46 ++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 server/controllers/StatsController.js diff --git a/server/controllers/StatsController.js b/server/controllers/StatsController.js new file mode 100644 index 00000000..36df4330 --- /dev/null +++ b/server/controllers/StatsController.js @@ -0,0 +1,75 @@ +const { Request, Response, NextFunction } = require('express') +const Logger = require('../Logger') + +const adminStats = require('../utils/queries/adminStats') + +/** + * @typedef RequestUserObject + * @property {import('../models/User')} user + * + * @typedef {Request & RequestUserObject} RequestWithUser + */ + +class StatsController { + constructor() {} + + /** + * GET: /api/stats/server + * Currently not in use + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getServerStats(req, res) { + Logger.debug('[StatsController] getServerStats') + const totalSize = await adminStats.getTotalSize() + const numAudioFiles = await adminStats.getNumAudioFiles() + + res.json({ + books: { + ...totalSize.books, + numAudioFiles: numAudioFiles.numBookAudioFiles + }, + podcasts: { + ...totalSize.podcasts, + numAudioFiles: numAudioFiles.numPodcastAudioFiles + }, + total: { + ...totalSize.total, + numAudioFiles: numAudioFiles.numAudioFiles + } + }) + } + + /** + * GET: /api/stats/year/:year + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async getAdminStatsForYear(req, res) { + const year = Number(req.params.year) + if (isNaN(year) || year < 2000 || year > 9999) { + Logger.error(`[StatsController] Invalid year "${year}"`) + return res.status(400).send('Invalid year') + } + const stats = await adminStats.getStatsForYear(year) + res.json(stats) + } + + /** + * + * @param {RequestWithUser} req + * @param {Response} res + * @param {NextFunction} next + */ + async middleware(req, res, next) { + if (!req.user.isAdminOrUp) { + Logger.error(`[StatsController] Non-root user "${req.user.username}" attempted to access stats route`) + return res.sendStatus(403) + } + + next() + } +} +module.exports = new StatsController() diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 78a5291d..67a2ffbc 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -33,8 +33,7 @@ const RSSFeedController = require('../controllers/RSSFeedController') const CustomMetadataProviderController = require('../controllers/CustomMetadataProviderController') const MiscController = require('../controllers/MiscController') const ShareController = require('../controllers/ShareController') - -const { getTitleIgnorePrefix } = require('../utils/index') +const StatsController = require('../controllers/StatsController') class ApiRouter { constructor(Server) { @@ -320,6 +319,12 @@ class ApiRouter { this.router.post('/share/mediaitem', ShareController.createMediaItemShare.bind(this)) this.router.delete('/share/mediaitem/:id', ShareController.deleteMediaItemShare.bind(this)) + // + // Stats Routes + // + this.router.get('/stats/year/:year', StatsController.getAdminStatsForYear.bind(this)) + this.router.get('/stats/server', StatsController.getServerStats.bind(this)) + // // Misc Routes // @@ -338,7 +343,6 @@ 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)) this.router.get('/logger-data', MiscController.getLoggerData.bind(this)) } diff --git a/server/utils/queries/adminStats.js b/server/utils/queries/adminStats.js index 9d7f572a..3184fbe3 100644 --- a/server/utils/queries/adminStats.js +++ b/server/utils/queries/adminStats.js @@ -167,5 +167,51 @@ module.exports = { topNarrators, topGenres } + }, + + /** + * Get total file size and number of items for books and podcasts + * + * @typedef {Object} SizeObject + * @property {number} totalSize + * @property {number} numItems + * + * @returns {Promise<{books: SizeObject, podcasts: SizeObject, total: SizeObject}}>} + */ + async getTotalSize() { + const [mediaTypeStats] = await Database.sequelize.query(`SELECT li.mediaType, SUM(li.size) AS totalSize, COUNT(*) AS numItems FROM libraryItems li group by li.mediaType;`) + const bookStats = mediaTypeStats.find((m) => m.mediaType === 'book') + const podcastStats = mediaTypeStats.find((m) => m.mediaType === 'podcast') + + return { + books: { + totalSize: bookStats?.totalSize || 0, + numItems: bookStats?.numItems || 0 + }, + podcasts: { + totalSize: podcastStats?.totalSize || 0, + numItems: podcastStats?.numItems || 0 + }, + total: { + totalSize: (bookStats?.totalSize || 0) + (podcastStats?.totalSize || 0), + numItems: (bookStats?.numItems || 0) + (podcastStats?.numItems || 0) + } + } + }, + + /** + * Get total number of audio files for books and podcasts + * + * @returns {Promise<{numBookAudioFiles: number, numPodcastAudioFiles: number, numAudioFiles: number}>} + */ + async getNumAudioFiles() { + const [numBookAudioFilesRow] = await Database.sequelize.query(`SELECT SUM(json_array_length(b.audioFiles)) AS numAudioFiles FROM books b;`) + const numBookAudioFiles = numBookAudioFilesRow[0]?.numAudioFiles || 0 + const numPodcastAudioFiles = await Database.podcastEpisodeModel.count() + return { + numBookAudioFiles, + numPodcastAudioFiles, + numAudioFiles: numBookAudioFiles + numPodcastAudioFiles + } } } From 73c1ea92f345f24341ea88bcdf634e9ef97e86ca Mon Sep 17 00:00:00 2001 From: advplyr Date: Sat, 29 Mar 2025 17:37:13 -0500 Subject: [PATCH 2/2] Add admin middleware for StatsController --- server/controllers/StatsController.js | 2 +- server/routers/ApiRouter.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/controllers/StatsController.js b/server/controllers/StatsController.js index 36df4330..32ed1973 100644 --- a/server/controllers/StatsController.js +++ b/server/controllers/StatsController.js @@ -65,7 +65,7 @@ class StatsController { */ async middleware(req, res, next) { if (!req.user.isAdminOrUp) { - Logger.error(`[StatsController] Non-root user "${req.user.username}" attempted to access stats route`) + Logger.error(`[StatsController] Non-admin user "${req.user.username}" attempted to access stats route`) return res.sendStatus(403) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 67a2ffbc..ecb1555f 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -322,8 +322,8 @@ class ApiRouter { // // Stats Routes // - this.router.get('/stats/year/:year', StatsController.getAdminStatsForYear.bind(this)) - this.router.get('/stats/server', StatsController.getServerStats.bind(this)) + this.router.get('/stats/year/:year', StatsController.middleware.bind(this), StatsController.getAdminStatsForYear.bind(this)) + this.router.get('/stats/server', StatsController.middleware.bind(this), StatsController.getServerStats.bind(this)) // // Misc Routes