diff --git a/client/components/stats/PreviewIcons.vue b/client/components/stats/PreviewIcons.vue index 488eae49..7bda889b 100644 --- a/client/components/stats/PreviewIcons.vue +++ b/client/components/stats/PreviewIcons.vue @@ -18,7 +18,7 @@ -
{{ $strings.MessageNoAuthors }}
@@ -114,43 +114,49 @@ export default { return this.$store.state.user.user }, totalItems() { - return this.libraryStats ? this.libraryStats.totalItems : 0 + return this.libraryStats?.totalItems || 0 }, genresWithCount() { - return this.libraryStats ? this.libraryStats.genresWithCount : [] + return this.libraryStats?.genresWithCount || [] }, top5Genres() { - return this.genresWithCount.slice(0, 5) + return this.genresWithCount?.slice(0, 5) || [] }, top10LongestItems() { - return this.libraryStats ? this.libraryStats.longestItems || [] : [] + return this.libraryStats?.longestItems || [] }, longestItemDuration() { if (!this.top10LongestItems.length) return 0 return this.top10LongestItems[0].duration }, top10LargestItems() { - return this.libraryStats ? this.libraryStats.largestItems || [] : [] + return this.libraryStats?.largestItems || [] }, largestItemSize() { if (!this.top10LargestItems.length) return 0 return this.top10LargestItems[0].size }, authorsWithCount() { - return this.libraryStats ? this.libraryStats.authorsWithCount : [] + return this.libraryStats?.authorsWithCount || [] }, mostUsedAuthorCount() { if (!this.authorsWithCount.length) return 0 return this.authorsWithCount[0].count }, top10Authors() { - return this.authorsWithCount.slice(0, 10) + return this.authorsWithCount?.slice(0, 10) || [] }, currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, currentLibraryName() { return this.$store.getters['libraries/getCurrentLibraryName'] + }, + currentLibraryMediaType() { + return this.$store.getters['libraries/getCurrentLibraryMediaType'] + }, + isBookLibrary() { + return this.currentLibraryMediaType === 'book' } }, methods: { diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index b9cf29a8..502d1cd5 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -17,6 +17,7 @@ const naturalSort = createNewSortInstance({ const Database = require('../Database') const libraryFilters = require('../utils/queries/libraryFilters') const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') +const authorFilters = require('../utils/queries/authorFilters') class LibraryController { constructor() { } @@ -809,23 +810,44 @@ class LibraryController { res.json(matches) } + /** + * GET: /api/libraries/:id/stats + * Get stats for library + * @param {import('express').Request} req + * @param {import('express').Response} res + */ async stats(req, res) { - var libraryItems = req.libraryItems - var authorsWithCount = libraryHelpers.getAuthorsWithCount(libraryItems) - var genresWithCount = libraryHelpers.getGenresWithCount(libraryItems) - var durationStats = libraryHelpers.getItemDurationStats(libraryItems) - var sizeStats = libraryHelpers.getItemSizeStats(libraryItems) - var stats = { - totalItems: libraryItems.length, - totalAuthors: Object.keys(authorsWithCount).length, - totalGenres: Object.keys(genresWithCount).length, - totalDuration: durationStats.totalDuration, - longestItems: durationStats.longestItems, - numAudioTracks: durationStats.numAudioTracks, - totalSize: libraryHelpers.getLibraryItemsTotalSize(libraryItems), - largestItems: sizeStats.largestItems, - authorsWithCount, - genresWithCount + const stats = { + largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10) + } + + if (req.library.isBook) { + const authors = await authorFilters.getAuthorsWithCount(req.library.id) + const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id) + const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id) + const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10) + + stats.totalAuthors = authors.length + stats.authorsWithCount = authors + stats.totalGenres = genres.length + stats.genresWithCount = genres + stats.totalItems = bookStats.totalItems + stats.longestItems = longestBooks + stats.totalSize = bookStats.totalSize + stats.totalDuration = bookStats.totalDuration + stats.numAudioTracks = bookStats.numAudioFiles + } else { + const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id) + const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id) + const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10) + + stats.totalGenres = genres.length + stats.genresWithCount = genres + stats.totalItems = podcastStats.totalItems + stats.longestItems = longestPodcasts + stats.totalSize = podcastStats.totalSize + stats.totalDuration = podcastStats.totalDuration + stats.numAudioTracks = podcastStats.numAudioFiles } res.json(stats) } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index fff3bc91..9e3b5474 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -87,7 +87,7 @@ class ApiRouter { this.router.get('/libraries/:id/personalized', LibraryController.middleware.bind(this), LibraryController.getLibraryUserPersonalizedOptimal.bind(this)) this.router.get('/libraries/:id/filterdata', LibraryController.middlewareNew.bind(this), LibraryController.getLibraryFilterData.bind(this)) this.router.get('/libraries/:id/search', LibraryController.middlewareNew.bind(this), LibraryController.search.bind(this)) - this.router.get('/libraries/:id/stats', LibraryController.middleware.bind(this), LibraryController.stats.bind(this)) + this.router.get('/libraries/:id/stats', LibraryController.middlewareNew.bind(this), LibraryController.stats.bind(this)) this.router.get('/libraries/:id/authors', LibraryController.middlewareNew.bind(this), LibraryController.getAuthors.bind(this)) this.router.get('/libraries/:id/narrators', LibraryController.middlewareNew.bind(this), LibraryController.getNarrators.bind(this)) this.router.patch('/libraries/:id/narrators/:narratorId', LibraryController.middlewareNew.bind(this), LibraryController.updateNarrator.bind(this)) diff --git a/server/utils/queries/authorFilters.js b/server/utils/queries/authorFilters.js new file mode 100644 index 00000000..c7f85c4a --- /dev/null +++ b/server/utils/queries/authorFilters.js @@ -0,0 +1,69 @@ +const Sequelize = require('sequelize') +const Logger = require('../../Logger') +const Database = require('../../Database') + +module.exports = { + /** + * Get authors with count of num books + * @param {string} libraryId + * @returns {{id:string, name:string, count:number}} + */ + async getAuthorsWithCount(libraryId) { + const authors = await Database.authorModel.findAll({ + where: [ + { + libraryId + }, + Sequelize.where(Sequelize.literal('count'), { + [Sequelize.Op.gt]: 0 + }) + ], + attributes: [ + 'id', + 'name', + [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'count'] + ], + order: [ + ['count', 'DESC'] + ] + }) + return authors.map(au => { + return { + id: au.id, + name: au.name, + count: au.dataValues.count + } + }) + }, + + /** + * Search authors + * @param {string} libraryId + * @param {string} query + * @returns {object[]} oldAuthor with numBooks + */ + async search(libraryId, query) { + const authors = await Database.authorModel.findAll({ + where: { + name: { + [Sequelize.Op.substring]: query + }, + libraryId + }, + attributes: { + include: [ + [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks'] + ] + }, + limit, + offset + }) + const authorMatches = [] + for (const author of authors) { + const oldAuthor = author.getOldAuthor().toJSON() + oldAuthor.numBooks = author.dataValues.numBooks + authorMatches.push(oldAuthor) + } + return authorMatches + } +} diff --git a/server/utils/queries/libraryItemFilters.js b/server/utils/queries/libraryItemFilters.js index e7ffe701..476d7708 100644 --- a/server/utils/queries/libraryItemFilters.js +++ b/server/utils/queries/libraryItemFilters.js @@ -180,5 +180,41 @@ module.exports = { } else { return libraryItemsPodcastFilters.search(oldUser, oldLibrary, query, limit, 0) } + }, + + /** + * Get largest items in library + * @param {string} libraryId + * @param {number} limit + * @returns {Promise<{ id:string, title:string, size:number }[]>} + */ + async getLargestItems(libraryId, limit) { + const libraryItems = await Database.libraryItemModel.findAll({ + attributes: ['id', 'mediaId', 'mediaType', 'size'], + where: { + libraryId + }, + include: [ + { + model: Database.bookModel, + attributes: ['id', 'title'] + }, + { + model: Database.podcastModel, + attributes: ['id', 'title'] + } + ], + order: [ + ['size', 'DESC'] + ], + limit + }) + return libraryItems.map(libraryItem => { + return { + id: libraryItem.id, + title: libraryItem.media.title, + size: libraryItem.size + } + }) } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsBookFilters.js b/server/utils/queries/libraryItemsBookFilters.js index 1a7fcbdc..c5499148 100644 --- a/server/utils/queries/libraryItemsBookFilters.js +++ b/server/utils/queries/libraryItemsBookFilters.js @@ -1,6 +1,7 @@ const Sequelize = require('sequelize') const Database = require('../../Database') const Logger = require('../../Logger') +const authorFilters = require('./authorFilters') module.exports = { /** @@ -1098,27 +1099,7 @@ module.exports = { } // Search authors - const authors = await Database.authorModel.findAll({ - where: { - name: { - [Sequelize.Op.substring]: query - }, - libraryId: oldLibrary.id - }, - attributes: { - include: [ - [Sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 'numBooks'] - ] - }, - limit, - offset - }) - const authorMatches = [] - for (const author of authors) { - const oldAuthor = author.getOldAuthor().toJSON() - oldAuthor.numBooks = author.dataValues.numBooks - authorMatches.push(oldAuthor) - } + const authorMatches = await authorFilters.search(oldLibrary.id, query) return { book: itemMatches, @@ -1127,5 +1108,71 @@ module.exports = { series: seriesMatches, authors: authorMatches } + }, + + /** + * Genres with num books + * @param {string} libraryId + * @returns {{genre:string, count:number}[]} + */ + async getGenresWithCount(libraryId) { + const genres = [] + const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM books b, libraryItems li, json_each(b.genres) WHERE json_valid(b.genres) AND b.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, { + replacements: { + libraryId + }, + raw: true + }) + for (const row of genreResults) { + genres.push({ + genre: row.value, + count: row.numItems + }) + } + return genres + }, + + /** + * Get stats for book library + * @param {string} libraryId + * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>} + */ + async getBookLibraryStats(libraryId) { + const [statResults] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, SUM(json_array_length(b.audioFiles)) AS numAudioFiles, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.libraryId = :libraryId;`, { + replacements: { + libraryId + } + }) + return statResults[0] + }, + + /** + * Get longest books in library + * @param {string} libraryId + * @param {number} limit + * @returns {Promise<{ id:string, title:string, duration:number }[]>} + */ + async getLongestBooks(libraryId, limit) { + const books = await Database.bookModel.findAll({ + attributes: ['id', 'title', 'duration'], + include: { + model: Database.libraryItemModel, + attributes: ['id', 'libraryId'], + where: { + libraryId + } + }, + order: [ + ['duration', 'DESC'] + ], + limit + }) + return books.map(book => { + return { + id: book.libraryItem.id, + title: book.title, + duration: book.duration + } + }) } } \ No newline at end of file diff --git a/server/utils/queries/libraryItemsPodcastFilters.js b/server/utils/queries/libraryItemsPodcastFilters.js index e14718e5..937b6d21 100644 --- a/server/utils/queries/libraryItemsPodcastFilters.js +++ b/server/utils/queries/libraryItemsPodcastFilters.js @@ -455,5 +455,75 @@ module.exports = { }) return episodeResults + }, + + /** + * Get stats for podcast library + * @param {string} libraryId + * @returns {Promise<{ totalSize:number, totalDuration:number, numAudioFiles:number, totalItems:number}>} + */ + async getPodcastLibraryStats(libraryId) { + const [statResults] = await Database.sequelize.query(`SELECT SUM(json_extract(pe.audioFile, '$.duration')) AS totalDuration, SUM(li.size) AS totalSize, COUNT(DISTINCT(li.id)) AS totalItems, COUNT(pe.id) AS numAudioFiles FROM libraryItems li, podcasts p LEFT OUTER JOIN podcastEpisodes pe ON pe.podcastId = p.id WHERE p.id = li.mediaId AND li.libraryId = :libraryId;`, { + replacements: { + libraryId + } + }) + return statResults[0] + }, + + /** + * Genres with num podcasts + * @param {string} libraryId + * @returns {{genre:string, count:number}[]} + */ + async getGenresWithCount(libraryId) { + const genres = [] + const [genreResults] = await Database.sequelize.query(`SELECT value, count(*) AS numItems FROM podcasts p, libraryItems li, json_each(p.genres) WHERE json_valid(p.genres) AND p.id = li.mediaId AND li.libraryId = :libraryId GROUP BY value ORDER BY numItems DESC;`, { + replacements: { + libraryId + }, + raw: true + }) + for (const row of genreResults) { + genres.push({ + genre: row.value, + count: row.numItems + }) + } + return genres + }, + + /** + * Get longest podcasts in library + * @param {string} libraryId + * @param {number} limit + * @returns {Promise<{ id:string, title:string, duration:number }[]>} + */ + async getLongestPodcasts(libraryId, limit) { + const podcasts = await Database.podcastModel.findAll({ + attributes: [ + 'id', + 'title', + [Sequelize.literal(`(SELECT SUM(json_extract(pe.audioFile, '$.duration')) FROM podcastEpisodes pe WHERE pe.podcastId = podcast.id)`), 'duration'] + ], + include: { + model: Database.libraryItemModel, + attributes: ['id', 'libraryId'], + where: { + libraryId + } + }, + order: [ + ['duration', 'DESC'] + ], + limit + }) + return podcasts.map(podcast => { + return { + id: podcast.libraryItem.id, + title: podcast.title, + duration: podcast.dataValues.duration + } + }) } } \ No newline at end of file