Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature - New view: merged all stats from all libraries #3335

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions client/components/app/ConfigSideNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ export default {
id: 'config-authentication',
title: this.$strings.HeaderAuthentication,
path: '/config/authentication'
},
{
id: 'all-libraries-stats',
title: this.$strings.HeaderAllLibrariesStats,
path: '/config/libraries-stats'
}
]

Expand Down
174 changes: 174 additions & 0 deletions client/pages/config/libraries-stats.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
<template>
<app-settings-content :header-text="$strings.HeaderAllLibrariesStats" class="!mb-4">
<div class="w-full max-w-4xl mx-auto">
<stats-preview-icons v-if="totalItems" :library-stats="combinedLibraryStats" />

<div class="flex lg:flex-row flex-wrap justify-between flex-col mt-8">
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop5Genres }}</h1>
<p v-if="!top5Genres.length">{{ $strings.MessageNoGenres }}</p>
<template v-for="genre in top5Genres">
<div :key="genre.genre" class="w-full py-2">
<div class="flex items-end mb-1">
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalItems) }}&nbsp;%</p>
<div class="flex-grow" />
<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=genres.${$encode(genre.genre)}`" class="text-base text-white text-opacity-70 hover:underline">
{{ genre.genre }}
</nuxt-link>
</div>
<div class="w-full rounded-full h-3 bg-primary bg-opacity-50 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * genre.count) / totalItems) + '%' }" />
</div>
</div>
</template>
</div>
<div v-if="top10Authors.length" class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsTop10Authors }}</h1>
<p v-if="!top10Authors.length">{{ $strings.MessageNoAuthors }}</p>
<template v-for="(author, index) in top10Authors">
<div :key="author.id" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-36 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/library/${currentLibraryId}/bookshelf?filter=authors.${$encode(author.id)}`" class="hover:underline">{{ author.name }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * author.count) / mostUsedAuthorCount) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ author.count }}</p>
</div>
</div>
</div>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLongestItems }}</h1>
<p v-if="!top10LongestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LongestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.duration) / longestItemDuration) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold">{{ (ab.duration / 3600).toFixed(1) }}</p>
</div>
</div>
</div>
</template>
</div>
<div class="w-80 my-6 mx-auto">
<h1 class="text-2xl mb-4">{{ $strings.HeaderStatsLargestItems }}</h1>
<p v-if="!top10LargestItems.length">{{ $strings.MessageNoItems }}</p>
<template v-for="(ab, index) in top10LargestItems">
<div :key="index" class="w-full py-2">
<div class="flex items-center mb-1">
<p class="text-sm text-white text-opacity-70 w-44 pr-2 truncate">
{{ index + 1 }}.&nbsp;&nbsp;&nbsp;&nbsp;<nuxt-link :to="`/item/${ab.id}`" class="hover:underline">{{ ab.title }}</nuxt-link>
</p>
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
<div class="bg-yellow-400 h-full rounded-full" :style="{ width: Math.round((100 * ab.size) / largestItemSize) + '%' }" />
</div>
<div class="w-4 ml-3">
<p class="text-sm font-bold whitespace-nowrap">{{ $bytesPretty(ab.size) }}</p>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</app-settings-content>
</template>

<script>
export default {
asyncData({ redirect, store }) {
if (!store.getters['user/getIsAdminOrUp']) {
redirect('/')
return
}
return {}
},
data() {
return {
libraryStats: []
}
},
computed: {
combinedLibraryStats() {
return this.libraryStats.reduce(
(combined, stats) => {
combined.totalItems += stats.totalItems || 0
combined.totalDuration += stats.totalDuration || 0
combined.totalAuthors += stats.totalAuthors || 0
combined.totalSize += stats.totalSize || 0
combined.numAudioTracks += stats.numAudioTracks || 0
combined.genresWithCount = [...combined.genresWithCount, ...(stats.genresWithCount || [])].sort((a, b) => b.count - a.count).slice(0, 10)
combined.longestItems = [...combined.longestItems, ...(stats.longestItems || [])].sort((a, b) => b.duration - a.duration).slice(0, 10)
combined.largestItems = [...combined.largestItems, ...(stats.largestItems || [])].sort((a, b) => b.size - a.size).slice(0, 10)
combined.authorsWithCount = [...combined.authorsWithCount, ...(stats.authorsWithCount || [])].sort((a, b) => b.count - a.count).slice(0, 10)
return combined
},
{
totalItems: 0,
totalDuration: 0,
totalAuthors: 0,
totalSize: 0,
numAudioTracks: 0,
genresWithCount: [],
longestItems: [],
largestItems: [],
authorsWithCount: []
}
)
},
totalItems() {
return this.combinedLibraryStats.totalItems || 0
},
top5Genres() {
return this.combinedLibraryStats.genresWithCount?.slice(0, 5) || []
},
top10LongestItems() {
return this.combinedLibraryStats.longestItems || []
},
longestItemDuration() {
if (!this.top10LongestItems.length) return 0
return this.top10LongestItems[0].duration
},
top10LargestItems() {
return this.combinedLibraryStats.largestItems || []
},
largestItemSize() {
if (!this.top10LargestItems.length) return 0
return this.top10LargestItems[0].size
},
top10Authors() {
return this.combinedLibraryStats.authorsWithCount?.slice(0, 10) || []
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
mostUsedAuthorCount() {
if (!this.combinedLibraryStats.authorsWithCount.length) return 0
return this.combinedLibraryStats.authorsWithCount[0].count
},
},
methods: {
async init() {
this.libraryStatsInformation = await this.$axios.$get(`/api/libraries/stats`).catch((err) => {
console.error('Failed to get library stats', err)
})
this.libraryStats = this.libraryStatsInformation.map(entry => {
return { ...entry.stats };
});
}
},
mounted() {
this.init()
}
}
</script>
1 change: 1 addition & 0 deletions client/strings/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
"ErrorUploadLacksTitle": "Must have a title",
"HeaderAccount": "Account",
"HeaderAdvanced": "Advanced",
"HeaderAllLibrariesStats": "All Libraries Stats",
"HeaderAppriseNotificationSettings": "Apprise Notification Settings",
"HeaderAudioTracks": "Audio Tracks",
"HeaderAudiobookTools": "Audiobook File Management Tools",
Expand Down
61 changes: 29 additions & 32 deletions server/controllers/LibraryController.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,30 @@ const authorFilters = require('../utils/queries/authorFilters')
class LibraryController {
constructor() {}

/**
* GET: /api/libraries/stats
* Get stats for all libraries and respond with JSON
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async allStats(req, res) {
try {
const allLibrariesIds = await Database.libraryModel.getAllLibraryIds();
const allStats = [];

for (let i = 0; i < allLibrariesIds.length; i++) {
const library = await Database.libraryModel.getOldById(allLibrariesIds[i]);
req.library = library
const libraryStats = await libraryHelpers.getLibraryStats(req);
allStats.push({ library: library, stats: libraryStats });
}

res.json(allStats);
} catch (error) {
res.status(500).json({ error: error.message });
}
}

/**
* POST: /api/libraries
* Create a new library
Expand Down Expand Up @@ -718,39 +742,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
try {
const stats = await libraryHelpers.getLibraryStats(req);
res.json(stats);
} catch (error) {
res.status(500).json({ error: error.message });
}
res.json(stats)
}

/**
Expand Down
1 change: 1 addition & 0 deletions server/routers/ApiRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,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))
Expand Down
46 changes: 46 additions & 0 deletions server/utils/libraryHelpers.js
Original file line number Diff line number Diff line change
@@ -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
})
Expand Down Expand Up @@ -78,6 +82,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
Expand Down
Loading