diff --git a/client/components/app/BookShelfToolbar.vue b/client/components/app/BookShelfToolbar.vue index 74157b1841..898250fda2 100644 --- a/client/components/app/BookShelfToolbar.vue +++ b/client/components/app/BookShelfToolbar.vue @@ -131,7 +131,8 @@ export default { totalEntities: 0, processingSeries: false, processingIssues: false, - processingAuthors: false + processingAuthors: false, + showAllLibraryStats: false } }, computed: { @@ -235,6 +236,9 @@ export default { currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, + currentLibraryName() { + return this.$store.getters['libraries/getCurrentLibraryName'] + }, libraryProvider() { return this.$store.getters['libraries/getLibraryProvider'](this.currentLibraryId) || 'google' }, diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue index b42a560ea3..fe71db4994 100644 --- a/client/components/app/ConfigSideNav.vue +++ b/client/components/app/ConfigSideNav.vue @@ -114,9 +114,9 @@ export default { if (this.currentLibraryId) { configRoutes.push({ - id: 'library-stats', - title: this.$strings.HeaderLibraryStats, - path: `/library/${this.currentLibraryId}/stats` + id: 'config-server-stats', + title: this.$strings.HeaderServerStats, + path: `/config/server-stats` }) configRoutes.push({ id: 'config-stats', diff --git a/client/components/app/SideRail.vue b/client/components/app/SideRail.vue index 2c6fd5a239..7bc9cab8d9 100644 --- a/client/components/app/SideRail.vue +++ b/client/components/app/SideRail.vue @@ -79,14 +79,6 @@
- - - -

{{ $strings.ButtonStats }}

- -
- - @@ -103,6 +95,14 @@
+ + + +

{{ $strings.ButtonStats }}

+ +
+ + warning diff --git a/client/components/stats/PreviewIcons.vue b/client/components/stats/PreviewIcons.vue index 7eea9d5102..b9f89e5da1 100644 --- a/client/components/stats/PreviewIcons.vue +++ b/client/components/stats/PreviewIcons.vue @@ -10,6 +10,14 @@
+
+ podcasts +
+

{{ $formatNumber(numAudioTracks) }}

+

{{ $strings.LabelEpisodes }}

+
+
+
show_chart
@@ -36,7 +44,7 @@
-
+
audio_file

{{ $formatNumber(numAudioTracks) }}

@@ -52,18 +60,22 @@ export default { libraryStats: { type: Object, default: () => {} - } + }, + mediaType: null }, data() { return {} }, computed: { currentLibraryMediaType() { - return this.$store.getters['libraries/getCurrentLibraryMediaType'] + return this.mediaType || this.$store.getters['libraries/getCurrentLibraryMediaType'] }, isBookLibrary() { return this.currentLibraryMediaType === 'book' }, + isOverView(){ + return this.mediaType === 'overview' + }, user() { return this.$store.state.user.user }, diff --git a/client/pages/config/server-stats.vue b/client/pages/config/server-stats.vue new file mode 100644 index 0000000000..c8edcc3501 --- /dev/null +++ b/client/pages/config/server-stats.vue @@ -0,0 +1,168 @@ + + + diff --git a/client/pages/library/_library/stats.vue b/client/pages/library/_library/stats.vue index ae72c2e896..4348531cbb 100644 --- a/client/pages/library/_library/stats.vue +++ b/client/pages/library/_library/stats.vue @@ -155,9 +155,6 @@ export default { currentLibraryId() { return this.$store.state.libraries.currentLibraryId }, - currentLibraryName() { - return this.$store.getters['libraries/getCurrentLibraryName'] - }, currentLibraryMediaType() { return this.$store.getters['libraries/getCurrentLibraryMediaType'] }, diff --git a/client/strings/en-us.json b/client/strings/en-us.json index f7af6aedc5..c254a6e296 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -117,11 +117,13 @@ "HeaderAccount": "Account", "HeaderAddCustomMetadataProvider": "Add Custom Metadata Provider", "HeaderAdvanced": "Advanced", + "HeaderAllStats": "All Stats", "HeaderAppriseNotificationSettings": "Apprise Notification Settings", "HeaderAudioTracks": "Audio Tracks", "HeaderAudiobookTools": "Audiobook File Management Tools", "HeaderAuthentication": "Authentication", "HeaderBackups": "Backups", + "HeaderBookLibraries": "Book Libraries", "HeaderChangePassword": "Change Password", "HeaderChapters": "Chapters", "HeaderChooseAFolder": "Choose a Folder", @@ -174,6 +176,7 @@ "HeaderPlayerSettings": "Player Settings", "HeaderPlaylist": "Playlist", "HeaderPlaylistItems": "Playlist Items", + "HeaderPodcastLibraries": "Podcast Libraries", "HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPreviewCover": "Preview Cover", "HeaderRSSFeedGeneral": "RSS Details", @@ -185,6 +188,7 @@ "HeaderSchedule": "Schedule", "HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads", "HeaderScheduleLibraryScans": "Schedule Automatic Library Scans", + "HeaderServerStats": "Server Stats", "HeaderSession": "Session", "HeaderSetBackupSchedule": "Set Backup Schedule", "HeaderSettings": "Settings", diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index 216f7595da..4d66018d87 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -39,6 +39,90 @@ const authorFilters = require('../utils/queries/authorFilters') class LibraryController { constructor() {} + /** + * GET: /api/libraries/stats + * Get stats for all libraries and respond with JSON + * @param {RequestWithUser} req + * @param {Response} res + */ + async allStats(req, res) { + try { + const allStats = []; + const combinedStats = { + all: {}, + books: {}, + podcasts: {} + }; + let libraries = await Database.libraryModel.getAllWithFolders(); + const librariesAccessible = req.user.permissions?.librariesAccessible || []; + + if (librariesAccessible.length) { + libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id)); + } + + for (const library of libraries) { + req.library = library; + + // Fetch stats for the current library + const libraryStats = await libraryHelpers.getLibraryStats(req); + + // Add this library's stats to the array of individual stats + allStats.push({ + 'id': library.id, + 'name': library.name, + 'type': library.mediaType, + 'stats': libraryStats + }); + + // Combine stats for all categories + const categories = ['all']; + if (library.mediaType === 'book') categories.push('books'); + if (library.mediaType === 'podcast') categories.push('podcasts'); + + // Process each relevant category + categories.forEach(category => { + for (const [key, value] of Object.entries(libraryStats)) { + if (typeof value === "number") { + combinedStats[category][key] = (combinedStats[category][key] || 0) + value; + } else if (typeof value === "object") { + if (!combinedStats[category][key]) combinedStats[category][key] = []; + combinedStats[category][key].push(...Object.values(value)); + } + } + }); + } + + // Process arrays to keep top 10 entries for all categories + Object.keys(combinedStats).forEach(category => { + for (const key in combinedStats[category]) { + if (Array.isArray(combinedStats[category][key])) { + combinedStats[category][key] = combinedStats[category][key] + .sort((a, b) => { + const props = ['size', 'count', 'duration']; + for (const prop of props) { + if (a[prop] !== undefined && b[prop] !== undefined) { + return b[prop] - a[prop]; + } + } + return 0; + }) + .slice(0, 10); + } + } + }); + + // Respond with the aggregated stats + res.json({ + libraries: allStats, + combined: combinedStats + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } + } + + + /** * POST: /api/libraries * Create a new library @@ -940,39 +1024,12 @@ class LibraryController { * @param {Response} res */ async stats(req, res) { - const stats = { - largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10) - } - - if (req.library.mediaType === 'book') { - const authors = await authorFilters.getAuthorsWithCount(req.library.id, 10) - 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 = await authorFilters.getAuthorsTotalCount(req.library.id) - 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) + try { + const stats = await libraryHelpers.getLibraryStats(req); + res.json(stats); + } catch (error) { + res.status(500).json({ error: error.message }); + } } /** diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index db9e66c5fb..726a190fe4 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -68,6 +68,7 @@ class ApiRouter { this.router.get(/^\/libraries/i, this.apiCacheManager.middleware) this.router.post('/libraries', LibraryController.create.bind(this)) this.router.get('/libraries', LibraryController.findAll.bind(this)) + this.router.get('/libraries/stats', LibraryController.allStats.bind(this)) this.router.get('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.findOne.bind(this)) this.router.patch('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.update.bind(this)) this.router.delete('/libraries/:id', LibraryController.middleware.bind(this), LibraryController.delete.bind(this)) diff --git a/server/utils/libraryHelpers.js b/server/utils/libraryHelpers.js index 5702071e56..a61602a8c8 100644 --- a/server/utils/libraryHelpers.js +++ b/server/utils/libraryHelpers.js @@ -1,6 +1,10 @@ const { createNewSortInstance } = require('../libs/fastSort') const Database = require('../Database') const { getTitlePrefixAtEnd, isNullOrNaN, getTitleIgnorePrefix } = require('../utils/index') +const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters') +const authorFilters = require('../utils/queries/authorFilters') +const libraryItemFilters = require('../utils/queries/libraryItemFilters') +const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters') const naturalSort = createNewSortInstance({ comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare }) @@ -91,6 +95,48 @@ module.exports = { return filteredLibraryItems }, + /** + * Helper method to get stats for a specific library + * @param {import('express').Request} req + * @returns {Promise} stats + */ + async getLibraryStats(req) { + const stats = { + largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10) + }; + + if (req.library.mediaType === 'book') { + const authors = await authorFilters.getAuthorsWithCount(req.library.id, 10) + 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 = await authorFilters.getAuthorsTotalCount(req.library.id) + 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; + } + + return stats; + }, + /** * * @param {*} payload