This commit is contained in:
Finn Dittmar 2025-07-11 15:28:57 +02:00 committed by GitHub
commit 3eceb581c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 340 additions and 43 deletions

View File

@ -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'
},

View File

@ -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',

View File

@ -87,7 +87,7 @@
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary/80' : 'bg-bg/60'">
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/podcast/search`" class="w-full h-20 flex flex-col items-center justify-center text-white text-opacity-80 border-b border-primary border-opacity-70 hover:bg-primary cursor-pointer relative" :class="isPodcastSearchPage ? 'bg-primary bg-opacity-80' : 'bg-bg bg-opacity-60'">
<span class="abs-icons icon-podcast text-xl"></span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonAdd }}</p>
@ -103,6 +103,14 @@
<div v-show="isPodcastDownloadQueuePage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="isPodcastLibrary && userIsAdminOrUp" :to="`/library/${currentLibraryId}/stats`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-primary cursor-pointer relative" :class="isStatsPage ? 'bg-primary/80' : 'bg-bg/60'">
<span class="material-symbols text-2xl">&#xf190;</span>
<p class="pt-1.5 text-center leading-4" style="font-size: 0.9rem">{{ $strings.ButtonStats }}</p>
<div v-show="isStatsPage" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
</nuxt-link>
<nuxt-link v-if="numIssues" :to="`/library/${currentLibraryId}/bookshelf?filter=issues`" class="w-full h-20 flex flex-col items-center justify-center text-white/80 border-b border-primary/70 hover:bg-error/40 cursor-pointer relative" :class="showingIssues ? 'bg-error/40' : 'bg-error/20'">
<span class="material-symbols text-2xl">warning</span>

View File

@ -10,6 +10,14 @@
</div>
</div>
<div v-if="!isBookLibrary && !isOverView" class="flex p-2">
<span class="material-symbols text-5xl pt-1">podcasts</span>
<div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
<p class="text-xs md:text-sm text-white text-opacity-80">{{ $strings.LabelEpisodes }}</p>
</div>
</div>
<div class="flex p-2">
<span class="material-symbols text-5xl py-1">show_chart</span>
<div class="px-1">
@ -36,7 +44,7 @@
</div>
</div>
<div class="flex p-2">
<div v-if="isBookLibrary || isOverView" class="flex p-2">
<span class="material-symbols text-5xl pt-1">audio_file</span>
<div class="px-1">
<p class="text-4.5xl leading-none font-bold">{{ $formatNumber(numAudioTracks) }}</p>
@ -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
},

View File

@ -0,0 +1,168 @@
<template>
<div>
<app-settings-content v-if="serverStats != null" :header-text="$strings.HeaderAllStats">
<stats-preview-icons :library-stats="serverStats['combined']['all']" media-type="overview"/>
</app-settings-content>
<app-settings-content v-if="serverStats != null && bookLibraryListStats.length >= 1" :header-text="$strings.HeaderBookLibraries">
<stats-preview-icons :library-stats="serverStats['combined']['books']" media-type="book"/>
<table class="tracksTable max-w-3xl mx-auto mt-8">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelStatsItemsInLibrary }}</th>
<th class="text-left">{{ $strings.LabelStatsOverallHours }}</th>
<th class="text-left">{{ $strings.LabelStatsAuthors }}</th>
<th class="text-left">{{ $strings.LabelSize }}</th>
<th class="text-left">{{ $strings.LabelStatsAudioTracks }}</th>
</tr>
<tr v-for="library in bookLibraryListStats">
<td>
<p class="text-sm md:text-base text-gray-100">
<a :href="'/library/' + library.id + '/stats'" class="hover:underline" @click.prevent="switchLibrary(library.id)">
{{ library.name }}
</a>
</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ library.stats.totalItems }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ $formatNumber(totalHours(library.stats.totalDuration)) }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ library.stats.totalAuthors }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ $formatNumber(totalSizeNum(library.stats.totalSize)) }} {{totalSizeMod(library.stats.totalSize)}}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ library.stats.numAudioTracks }}</p>
</td>
</tr>
</table>
</app-settings-content>
<app-settings-content v-if="serverStats != null && podcastLibraryListStats.length >= 1" :header-text="$strings.HeaderPodcastLibraries">
<stats-preview-icons :library-stats="serverStats['combined']['podcasts']" media-type="podcast"/>
<table class="tracksTable max-w-3xl mx-auto mt-8">
<tr>
<th class="text-left">{{ $strings.LabelName }}</th>
<th class="text-left">{{ $strings.LabelStatsItemsInLibrary }}</th>
<th class="text-left">{{ $strings.LabelEpisodes }}</th>
<th class="text-left">{{ $strings.LabelStatsOverallHours }}</th>
<th class="text-left">{{ $strings.LabelSize }}</th>
</tr>
<tr v-for="library in podcastLibraryListStats">
<td>
<p class="text-sm md:text-base text-gray-100">
<a :href="'/library/' + library.id + '/stats'" class="hover:underline" @click.prevent="switchLibrary(library.id)">
{{ library.name }}
</a>
</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ library.stats.totalItems }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ library.stats.numAudioTracks }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ $formatNumber(totalHours(library.stats.totalDuration)) }}</p>
</td>
<td>
<p class="text-sm md:text-base text-gray-100">{{ $formatNumber(totalSizeNum(library.stats.totalSize)) }} {{totalSizeMod(library.stats.totalSize)}}</p>
</td>
</tr>
</table>
</app-settings-content>
</div>
</template>
<script>
export default {
asyncData({ redirect, store }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
return {}
},
data() {
return {
serverStats: null
}
},
computed: {
streamLibraryItem() {
return this.$store.state.streamLibraryItem
},
user() {
return this.$store.state.user.user
},
totalItems() {
return this.serverStats?.totalItems || 0
},
bookLibraryIndex() {
return this.serverStats?.libraries.findIndex((lib) => lib['type'] === 'book')
},
podcastLibraryIndex() {
return this.serverStats?.libraries.findIndex((lib) => lib['type'] === 'podcast')
},
bookLibraryListStats() {
if (this.bookLibraryIndex === -1) return []
if (this.podcastLibraryIndex !== -1) {
return this.serverStats['libraries'].slice(0, this.podcastLibraryIndex)
}
return this.serverStats['libraries']
},
podcastLibraryListStats() {
if (this.podcastLibraryIndex === -1) return []
return this.serverStats['libraries'].slice(this.podcastLibraryIndex)
}
},
methods: {
async init() {
this.serverStats = (await this.$axios.$get(`/api/libraries/stats`).catch((err) => {
console.error('Failed to get library stats', err)
var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
this.$toast.error(`Failed to get library stats: ${errorMsg}`)
}))
// Sort the libraries by type
this.serverStats['libraries'].sort((a, b) => {
if (a['type'] < b['type']) return -1
if (a['type'] > b['type']) return 1
return 0
})
},
totalHours(duration) {
return Math.round(duration / (60 * 60))
},
totalSizePretty(size) {
let totalSize = size || 0
return this.$bytesPretty(totalSize, 1)
},
totalSizeNum(size) {
return this.totalSizePretty(size).split(' ')[0]
},
totalSizeMod(size) {
return this.totalSizePretty(size).split(' ')[1]
},
async switchLibrary(libraryId) {
await this.$store.dispatch('libraries/fetch', libraryId);
await this.$router.push(`/library/${libraryId}/stats`)
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -157,9 +157,6 @@ export default {
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
},
currentLibraryMediaType() {
return this.$store.getters['libraries/getCurrentLibraryMediaType']
},

View File

@ -119,11 +119,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",
@ -176,6 +178,7 @@
"HeaderPlayerSettings": "Player Settings",
"HeaderPlaylist": "Playlist",
"HeaderPlaylistItems": "Playlist Items",
"HeaderPodcastLibraries": "Podcast Libraries",
"HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPresets": "Presets",
"HeaderPreviewCover": "Preview Cover",
@ -188,6 +191,7 @@
"HeaderSchedule": "Schedule",
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderServerStats": "Server Stats",
"HeaderSession": "Session",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",

View File

@ -40,6 +40,90 @@ const zipHelpers = require('../utils/zipHelpers')
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
@ -978,39 +1062,12 @@ class LibraryController {
* @param {Response} res
*/
async stats(req, res) {
const stats = {
largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)
try {
const stats = await libraryHelpers.getLibraryStats(req);
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
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)
}
/**

View File

@ -67,6 +67,7 @@ class ApiRouter {
this.router.get(/^\/libraries/, 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))

View File

@ -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<Object>} 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