mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-30 06:40:14 +02:00
Add:User listening stats page and new library stats
This commit is contained in:
parent
7845e06a24
commit
b80d735750
@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
|
||||||
|
|
||||||
<nuxt-link v-if="isRootUser" to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
<nuxt-link to="/config/stats" class="outline-none hover:text-gray-200 cursor-pointer w-8 h-8 flex items-center justify-center mx-1">
|
||||||
<span class="material-icons">equalizer</span>
|
<span class="material-icons">equalizer</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
|
@ -3,29 +3,10 @@
|
|||||||
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
<div class="md:hidden flex items-center justify-end pb-2 px-4 mb-1" @click="closeDrawer">
|
||||||
<span class="material-icons text-2xl">arrow_back</span>
|
<span class="material-icons text-2xl">arrow_back</span>
|
||||||
</div>
|
</div>
|
||||||
<nuxt-link to="/config" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Settings</p>
|
<nuxt-link v-for="route in configRoutes" :key="route.id" :to="route.path" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === route.id ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
||||||
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
<p>{{ route.title }}</p>
|
||||||
</nuxt-link>
|
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||||
<nuxt-link to="/config/libraries" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-libraries' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Libraries</p>
|
|
||||||
<div v-show="routeName === 'config-libraries'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link to="/config/users" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-users' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Users</p>
|
|
||||||
<div v-show="routeName === 'config-users'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link to="/config/backups" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-backups' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Backups</p>
|
|
||||||
<div v-show="routeName === 'config-backups'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link to="/config/log" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-log' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Log</p>
|
|
||||||
<div v-show="routeName === 'config-log'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
|
||||||
<nuxt-link to="/config/stats" class="w-full px-4 h-12 border-b border-opacity-0 flex items-center cursor-pointer relative" :class="routeName === 'config-stats' ? 'bg-primary bg-opacity-70' : 'hover:bg-primary hover:bg-opacity-30'">
|
|
||||||
<p>Stats</p>
|
|
||||||
<div v-show="routeName === 'config-stats'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && windowHeight > 700 && !isMobile ? '300px' : '65px' }">
|
<div class="w-full h-10 px-4 border-t border-black border-opacity-20 absolute left-0 flex flex-col justify-center" :style="{ bottom: streamAudiobook && windowHeight > 700 && !isMobile ? '300px' : '65px' }">
|
||||||
@ -47,6 +28,57 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
userIsRoot() {
|
||||||
|
return this.$store.getters['user/getIsRoot']
|
||||||
|
},
|
||||||
|
configRoutes() {
|
||||||
|
if (!this.userIsRoot) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'config-stats',
|
||||||
|
title: 'Your Stats',
|
||||||
|
path: '/config/stats'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'config',
|
||||||
|
title: 'Settings',
|
||||||
|
path: '/config'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-libraries',
|
||||||
|
title: 'Libraries',
|
||||||
|
path: '/config/libraries'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-users',
|
||||||
|
title: 'Users',
|
||||||
|
path: '/config/users'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-backups',
|
||||||
|
title: 'Backups',
|
||||||
|
path: '/config/backups'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-log',
|
||||||
|
title: 'Log',
|
||||||
|
path: '/config/log'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-library-stats',
|
||||||
|
title: 'Library Stats',
|
||||||
|
path: '/config/library-stats'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'config-stats',
|
||||||
|
title: 'Your Stats',
|
||||||
|
path: '/config/stats'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
wrapperClass() {
|
wrapperClass() {
|
||||||
var classes = []
|
var classes = []
|
||||||
if (this.drawerOpen) classes.push('translate-x-0')
|
if (this.drawerOpen) classes.push('translate-x-0')
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-96 my-6 mx-auto">
|
<div class="w-96 my-6 mx-auto">
|
||||||
<h1 class="text-2xl mb-4 font-book">Minutes Listening</h1>
|
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||||
<div class="relative w-96 h-72">
|
<div class="relative w-96 h-72">
|
||||||
<div class="absolute top-0 left-0">
|
<div class="absolute top-0 left-0">
|
||||||
<template v-for="lbl in yAxisLabels">
|
<template v-for="lbl in yAxisLabels">
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-wrap justify-between mt-6">
|
<div class="flex flex-wrap justify-center mt-6">
|
||||||
<div class="flex p-2">
|
<div class="flex px-2">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -9,20 +9,8 @@
|
|||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex p-2">
|
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div class="px-3">
|
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ userAudiobooksRead.length }}</p>
|
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books Read</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex px-4">
|
||||||
<span class="material-icons text-7xl">show_chart</span>
|
<span class="material-icons text-7xl">show_chart</span>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
||||||
@ -30,7 +18,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex px-4">
|
||||||
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||||
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
<path fill="currentColor" d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M12,13C14.67,13 20,14.33 20,17V20H4V17C4,14.33 9.33,13 12,13M12,14.9C9.03,14.9 5.9,16.36 5.9,17V18.1H18.1V17C18.1,16.36 14.97,14.9 12,14.9Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@ -40,11 +28,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex p-2">
|
<div class="flex px-4">
|
||||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
||||||
<div class="px-1">
|
<div class="px-1">
|
||||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
<p class="text-4xl md:text-5xl font-bold">{{ totalSizeNum }}</p>
|
||||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Size ({{ totalSizeMod }})</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex px-4">
|
||||||
|
<span class="material-icons-outlined text-6xl pt-1">audio_file</span>
|
||||||
|
<div class="px-1">
|
||||||
|
<p class="text-4xl md:text-5xl font-bold">{{ numAudioTracks }}</p>
|
||||||
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Audio Tracks</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -53,10 +49,6 @@
|
|||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
listeningStats: {
|
|
||||||
type: Object,
|
|
||||||
default: () => {}
|
|
||||||
},
|
|
||||||
libraryStats: {
|
libraryStats: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => {}
|
default: () => {}
|
||||||
@ -75,11 +67,8 @@ export default {
|
|||||||
totalAuthors() {
|
totalAuthors() {
|
||||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||||
},
|
},
|
||||||
userAudiobooks() {
|
numAudioTracks() {
|
||||||
return Object.values(this.user.audiobooks || {})
|
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||||
},
|
|
||||||
userAudiobooksRead() {
|
|
||||||
return this.userAudiobooks.filter((ab) => !!ab.isRead)
|
|
||||||
},
|
},
|
||||||
totalAudiobookDuration() {
|
totalAudiobookDuration() {
|
||||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||||
@ -88,9 +77,15 @@ export default {
|
|||||||
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
||||||
return totalHours
|
return totalHours
|
||||||
},
|
},
|
||||||
totalMinutesListening() {
|
totalSizePretty() {
|
||||||
if (!this.listeningStats) return 0
|
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||||
return Math.round(this.listeningStats.totalTime / 60)
|
return this.$bytesPretty(totalSize, 1)
|
||||||
|
},
|
||||||
|
totalSizeNum() {
|
||||||
|
return this.totalSizePretty.split(' ')[0]
|
||||||
|
},
|
||||||
|
totalSizeMod() {
|
||||||
|
return this.totalSizePretty.split(' ')[1]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {},
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
<widgets-online-indicator :value="!!usersOnline[user.id]" />
|
||||||
<span class="pl-2">{{ user.username }}</span> <span v-show="$isDev" class="text-xs text-gray-400 italic pl-4">({{ user.id }})</span>
|
<p class="pl-2 truncate">{{ user.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-sm">{{ user.type }}</td>
|
<td class="text-sm">{{ user.type }}</td>
|
||||||
|
@ -14,9 +14,12 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
asyncData({ store, redirect }) {
|
asyncData({ store, redirect, route }) {
|
||||||
if (!store.getters['user/getIsRoot']) {
|
if (!store.getters['user/getIsRoot']) {
|
||||||
redirect('/?error=unauthorized')
|
// Non-Root user only has access to the listening stats page
|
||||||
|
if (route.name !== 'config-stats') {
|
||||||
|
redirect('/config/stats')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@ -38,7 +41,7 @@ export default {
|
|||||||
currentPage() {
|
currentPage() {
|
||||||
if (!this.$route.name) return 'Settings'
|
if (!this.$route.name) return 'Settings'
|
||||||
var routeName = this.$route.name.split('-')
|
var routeName = this.$route.name.split('-')
|
||||||
if (routeName.length > 0) return routeName[1]
|
if (routeName.length > 0) return routeName.slice(1).join('-')
|
||||||
return 'Settings'
|
return 'Settings'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -72,7 +75,7 @@ export default {
|
|||||||
width: 900px;
|
width: 900px;
|
||||||
max-width: calc(100% - 176px);
|
max-width: calc(100% - 176px);
|
||||||
}
|
}
|
||||||
.configContent.page-stats {
|
.configContent.page-library-stats {
|
||||||
width: 1200px;
|
width: 1200px;
|
||||||
}
|
}
|
||||||
@media (max-width: 1024px) {
|
@media (max-width: 1024px) {
|
||||||
|
127
client/pages/config/library-stats.vue
Normal file
127
client/pages/config/library-stats.vue
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p class="text-xl">Stats for library {{ currentLibraryName }}</p>
|
||||||
|
|
||||||
|
<stats-preview-icons :library-stats="libraryStats" />
|
||||||
|
|
||||||
|
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
|
||||||
|
<div class="w-80 my-6 mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||||
|
<p v-if="!top5Genres.length">No Genres</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) / totalBooks) }} %</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-base font-book text-white text-opacity-70">{{ genre.genre }}</p>
|
||||||
|
</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) / totalBooks) + '%' }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="w-80 my-6 mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4 font-book">Top 10 Authors</h1>
|
||||||
|
<p v-if="!top10Authors.length">No Authors</p>
|
||||||
|
<template v-for="(author, index) in top10Authors">
|
||||||
|
<div :key="author.author" class="w-full py-2">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">{{ index + 1 }}. {{ author.author }}</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 font-book">Longest Audiobooks (hrs)</h1>
|
||||||
|
<p v-if="!top10LongestAudiobooks.length">No Audiobooks</p>
|
||||||
|
<template v-for="(ab, index) in top10LongestAudiobooks">
|
||||||
|
<div :key="index" class="w-full py-2">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<p class="text-sm font-book text-white text-opacity-70 w-44 pr-2 truncate">{{ index + 1 }}. {{ ab.title }}</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) / longestAudiobookDuration) + '%' }" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
libraryStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
currentLibraryId(newVal, oldVal) {
|
||||||
|
if (newVal) {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
user() {
|
||||||
|
return this.$store.state.user.user
|
||||||
|
},
|
||||||
|
totalBooks() {
|
||||||
|
return this.libraryStats ? this.libraryStats.totalBooks : 0
|
||||||
|
},
|
||||||
|
genresWithCount() {
|
||||||
|
return this.libraryStats ? this.libraryStats.genresWithCount : []
|
||||||
|
},
|
||||||
|
top5Genres() {
|
||||||
|
return this.genresWithCount.slice(0, 5)
|
||||||
|
},
|
||||||
|
top10LongestAudiobooks() {
|
||||||
|
return this.libraryStats ? this.libraryStats.longestAudiobooks || [] : []
|
||||||
|
},
|
||||||
|
longestAudiobookDuration() {
|
||||||
|
if (!this.top10LongestAudiobooks.length) return 0
|
||||||
|
return this.top10LongestAudiobooks[0].duration
|
||||||
|
},
|
||||||
|
authorsWithCount() {
|
||||||
|
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
||||||
|
},
|
||||||
|
mostUsedAuthorCount() {
|
||||||
|
if (!this.authorsWithCount.length) return 0
|
||||||
|
return this.authorsWithCount[0].count
|
||||||
|
},
|
||||||
|
top10Authors() {
|
||||||
|
return this.authorsWithCount.slice(0, 10)
|
||||||
|
},
|
||||||
|
currentLibraryId() {
|
||||||
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
currentLibraryName() {
|
||||||
|
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async init() {
|
||||||
|
this.libraryStats = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/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}`)
|
||||||
|
})
|
||||||
|
console.log('lib stats', this.libraryStats)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,40 +1,56 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<stats-preview-icons :listening-stats="listeningStats" :library-stats="libraryStats" />
|
<div class="flex justify-center">
|
||||||
|
<div class="flex p-2">
|
||||||
|
<svg class="h-14 w-14 md:h-18 md:w-18" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M19 1L14 6V17L19 12.5V1M21 5V18.5C19.9 18.15 18.7 18 17.5 18C15.8 18 13.35 18.65 12 19.5V6C10.55 4.9 8.45 4.5 6.5 4.5C4.55 4.5 2.45 4.9 1 6V20.65C1 20.9 1.25 21.15 1.5 21.15C1.6 21.15 1.65 21.1 1.75 21.1C3.1 20.45 5.05 20 6.5 20C8.45 20 10.55 20.4 12 21.5C13.35 20.65 15.8 20 17.5 20C19.15 20 20.85 20.3 22.25 21.05C22.35 21.1 22.4 21.1 22.5 21.1C22.75 21.1 23 20.85 23 20.6V6C22.4 5.55 21.75 5.25 21 5M10 18.41C8.75 18.09 7.5 18 6.5 18C5.44 18 4.18 18.19 3 18.5V7.13C3.91 6.73 5.14 6.5 6.5 6.5C7.86 6.5 9.09 6.73 10 7.13V18.41Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="px-3">
|
||||||
|
<p class="text-4xl md:text-5xl font-bold">{{ userAudiobooksRead.length }}</p>
|
||||||
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books Read</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex md:flex-row flex-wrap justify-between flex-col mt-12">
|
<div class="flex p-2">
|
||||||
<div class="w-80 my-6 mx-auto">
|
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
|
||||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
<div class="px-1">
|
||||||
<template v-for="genre in top5Genres">
|
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||||
<div :key="genre.genre" class="w-full py-2">
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||||
<div class="flex items-end mb-1">
|
</div>
|
||||||
<p class="text-2xl font-bold">{{ Math.round((100 * genre.count) / totalBooks) }} %</p>
|
|
||||||
<div class="flex-grow" />
|
|
||||||
<p class="text-base font-book text-white text-opacity-70">{{ genre.genre }}</p>
|
|
||||||
</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) / totalBooks) + '%' }" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="w-80 my-6 mx-auto">
|
|
||||||
<h1 class="text-2xl mb-4 font-book">Top 10 Authors</h1>
|
<div class="flex p-2">
|
||||||
<template v-for="(author, index) in top10Authors">
|
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
||||||
<div :key="author.author" class="w-full py-2">
|
<div class="px-1">
|
||||||
<div class="flex items-center mb-1">
|
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||||
<p class="text-sm font-book text-white text-opacity-70 w-36 pr-2 truncate">{{ index + 1 }}. {{ author.author }}</p>
|
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||||
<div class="flex-grow rounded-full h-2.5 bg-primary bg-opacity-0 overflow-hidden">
|
</div>
|
||||||
<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>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
<stats-daily-listening-chart :listening-stats="listeningStats" />
|
<stats-daily-listening-chart :listening-stats="listeningStats" />
|
||||||
|
<div class="w-80 my-6 mx-auto">
|
||||||
|
<h1 class="text-2xl mb-4 font-book">Recent Listening Sessions</h1>
|
||||||
|
<p v-if="!mostRecentListeningSessions.length">No Listening Sessions</p>
|
||||||
|
<template v-for="(book, index) in mostRecentListeningSessions">
|
||||||
|
<div :key="book.id" class="w-full py-0.5">
|
||||||
|
<div class="flex items-center mb-1">
|
||||||
|
<p class="text-sm font-book text-white text-opacity-70 w-6 truncate">{{ index + 1 }}.</p>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-book text-white text-opacity-80 truncate">{{ book.audiobookTitle }}</p>
|
||||||
|
<p class="text-xs text-white text-opacity-50">{{ $dateDistanceFromNow(book.lastUpdate) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<div class="w-18 text-right ml-3">
|
||||||
|
<p class="text-sm font-bold">{{ $elapsedPretty(book.timeListening) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@ -58,27 +74,31 @@ export default {
|
|||||||
user() {
|
user() {
|
||||||
return this.$store.state.user.user
|
return this.$store.state.user.user
|
||||||
},
|
},
|
||||||
totalBooks() {
|
|
||||||
return this.libraryStats ? this.libraryStats.totalBooks : 0
|
|
||||||
},
|
|
||||||
genresWithCount() {
|
|
||||||
return this.libraryStats ? this.libraryStats.genresWithCount : []
|
|
||||||
},
|
|
||||||
top5Genres() {
|
|
||||||
return this.genresWithCount.slice(0, 5)
|
|
||||||
},
|
|
||||||
authorsWithCount() {
|
|
||||||
return this.libraryStats ? this.libraryStats.authorsWithCount : []
|
|
||||||
},
|
|
||||||
mostUsedAuthorCount() {
|
|
||||||
if (!this.authorsWithCount.length) return 0
|
|
||||||
return this.authorsWithCount[0].count
|
|
||||||
},
|
|
||||||
top10Authors() {
|
|
||||||
return this.authorsWithCount.slice(0, 10)
|
|
||||||
},
|
|
||||||
currentLibraryId() {
|
currentLibraryId() {
|
||||||
return this.$store.state.libraries.currentLibraryId
|
return this.$store.state.libraries.currentLibraryId
|
||||||
|
},
|
||||||
|
userAudiobooks() {
|
||||||
|
return Object.values(this.user.audiobooks || {})
|
||||||
|
},
|
||||||
|
userAudiobooksRead() {
|
||||||
|
return this.userAudiobooks.filter((ab) => !!ab.isRead)
|
||||||
|
},
|
||||||
|
mostRecentBooksListened() {
|
||||||
|
if (!this.listeningStats) return []
|
||||||
|
var sorted = Object.values(this.listeningStats.books || {}).sort((a, b) => b.lastUpdate - a.lastUpdate)
|
||||||
|
return sorted.slice(0, 10)
|
||||||
|
},
|
||||||
|
mostRecentListeningSessions() {
|
||||||
|
if (!this.listeningStats) return []
|
||||||
|
return this.listeningStats.recentSessions || []
|
||||||
|
},
|
||||||
|
totalMinutesListening() {
|
||||||
|
if (!this.listeningStats) return 0
|
||||||
|
return Math.round(this.listeningStats.totalTime / 60)
|
||||||
|
},
|
||||||
|
totalDaysListened() {
|
||||||
|
if (!this.listeningStats) return 0
|
||||||
|
return Object.values(this.listeningStats.days).length
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@ -88,7 +108,6 @@ export default {
|
|||||||
var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
|
var errorMsg = err.response ? err.response.data || 'Unknown Error' : 'Unknown Error'
|
||||||
this.$toast.error(`Failed to get library stats: ${errorMsg}`)
|
this.$toast.error(`Failed to get library stats: ${errorMsg}`)
|
||||||
})
|
})
|
||||||
console.log('lib stats', this.libraryStats)
|
|
||||||
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||||
console.error('Failed to load listening sesions', err)
|
console.error('Failed to load listening sesions', err)
|
||||||
return []
|
return []
|
||||||
|
@ -13,6 +13,11 @@ export const getters = {
|
|||||||
getCurrentLibrary: state => {
|
getCurrentLibrary: state => {
|
||||||
return state.libraries.find(lib => lib.id === state.currentLibraryId)
|
return state.libraries.find(lib => lib.id === state.currentLibraryId)
|
||||||
},
|
},
|
||||||
|
getCurrentLibraryName: (state, getters) => {
|
||||||
|
var currentLibrary = getters.getCurrentLibrary
|
||||||
|
if (!currentLibrary) return ''
|
||||||
|
return currentLibrary.name
|
||||||
|
},
|
||||||
getSortedLibraries: state => () => {
|
getSortedLibraries: state => () => {
|
||||||
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
||||||
}
|
}
|
||||||
|
@ -458,14 +458,15 @@ class ApiController {
|
|||||||
books: {},
|
books: {},
|
||||||
days: {},
|
days: {},
|
||||||
dayOfWeek: {},
|
dayOfWeek: {},
|
||||||
today: 0
|
today: 0,
|
||||||
|
recentSessions: listeningSessions.slice(0, 10)
|
||||||
}
|
}
|
||||||
listeningSessions.forEach((s) => {
|
listeningSessions.forEach((s) => {
|
||||||
if (s.dayOfWeek) {
|
if (s.dayOfWeek) {
|
||||||
if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
|
if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
|
||||||
listeningStats.dayOfWeek[s.dayOfWeek] += s.timeListening
|
listeningStats.dayOfWeek[s.dayOfWeek] += s.timeListening
|
||||||
}
|
}
|
||||||
if (s.date) {
|
if (s.date && s.timeListening > 0) {
|
||||||
if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0
|
if (!listeningStats.days[s.date]) listeningStats.days[s.date] = 0
|
||||||
listeningStats.days[s.date] += s.timeListening
|
listeningStats.days[s.date] += s.timeListening
|
||||||
|
|
||||||
@ -473,8 +474,17 @@ class ApiController {
|
|||||||
listeningStats.today += s.timeListening
|
listeningStats.today += s.timeListening
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!listeningStats.books[s.audiobookId]) listeningStats.books[s.audiobookId] = 0
|
if (!listeningStats.books[s.audiobookId]) {
|
||||||
listeningStats.books[s.audiobookId] += s.timeListening
|
listeningStats.books[s.audiobookId] = {
|
||||||
|
id: s.audiobookId,
|
||||||
|
timeListening: s.timeListening,
|
||||||
|
title: s.audiobookTitle,
|
||||||
|
author: s.audiobookAuthor,
|
||||||
|
lastUpdate: s.lastUpdate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listeningStats.books[s.audiobookId].timeListening += s.timeListening
|
||||||
|
}
|
||||||
|
|
||||||
listeningStats.totalTime += s.timeListening
|
listeningStats.totalTime += s.timeListening
|
||||||
})
|
})
|
||||||
|
@ -375,11 +375,14 @@ class LibraryController {
|
|||||||
|
|
||||||
var authorsWithCount = libraryHelpers.getAuthorsWithCount(audiobooksInLibrary)
|
var authorsWithCount = libraryHelpers.getAuthorsWithCount(audiobooksInLibrary)
|
||||||
var genresWithCount = libraryHelpers.getGenresWithCount(audiobooksInLibrary)
|
var genresWithCount = libraryHelpers.getGenresWithCount(audiobooksInLibrary)
|
||||||
|
var abDurationStats = libraryHelpers.getAudiobookDurationStats(audiobooksInLibrary)
|
||||||
var stats = {
|
var stats = {
|
||||||
totalBooks: audiobooksInLibrary.length,
|
totalBooks: audiobooksInLibrary.length,
|
||||||
totalAuthors: Object.keys(authorsWithCount).length,
|
totalAuthors: Object.keys(authorsWithCount).length,
|
||||||
totalGenres: Object.keys(genresWithCount).length,
|
totalGenres: Object.keys(genresWithCount).length,
|
||||||
totalDuration: libraryHelpers.getAudiobooksTotalDuration(audiobooksInLibrary),
|
totalDuration: abDurationStats.totalDuration,
|
||||||
|
longestAudiobooks: abDurationStats.longstAudiobooks,
|
||||||
|
numAudioTracks: abDurationStats.numAudioTracks,
|
||||||
totalSize: libraryHelpers.getAudiobooksTotalSize(audiobooksInLibrary),
|
totalSize: libraryHelpers.getAudiobooksTotalSize(audiobooksInLibrary),
|
||||||
authorsWithCount,
|
authorsWithCount,
|
||||||
genresWithCount
|
genresWithCount
|
||||||
|
@ -10,6 +10,7 @@ class UserListeningSession {
|
|||||||
this.audiobookId = null
|
this.audiobookId = null
|
||||||
this.audiobookTitle = null
|
this.audiobookTitle = null
|
||||||
this.audiobookAuthor = null
|
this.audiobookAuthor = null
|
||||||
|
this.audiobookDuration = 0
|
||||||
this.audiobookGenres = []
|
this.audiobookGenres = []
|
||||||
|
|
||||||
this.date = null
|
this.date = null
|
||||||
@ -32,6 +33,7 @@ class UserListeningSession {
|
|||||||
audiobookId: this.audiobookId,
|
audiobookId: this.audiobookId,
|
||||||
audiobookTitle: this.audiobookTitle,
|
audiobookTitle: this.audiobookTitle,
|
||||||
audiobookAuthor: this.audiobookAuthor,
|
audiobookAuthor: this.audiobookAuthor,
|
||||||
|
audiobookDuration: this.audiobookDuration,
|
||||||
audiobookGenres: [...this.audiobookGenres],
|
audiobookGenres: [...this.audiobookGenres],
|
||||||
date: this.date,
|
date: this.date,
|
||||||
dayOfWeek: this.dayOfWeek,
|
dayOfWeek: this.dayOfWeek,
|
||||||
@ -48,6 +50,7 @@ class UserListeningSession {
|
|||||||
this.audiobookId = session.audiobookId
|
this.audiobookId = session.audiobookId
|
||||||
this.audiobookTitle = session.audiobookTitle
|
this.audiobookTitle = session.audiobookTitle
|
||||||
this.audiobookAuthor = session.audiobookAuthor
|
this.audiobookAuthor = session.audiobookAuthor
|
||||||
|
this.audiobookDuration = session.audiobookDuration || 0
|
||||||
this.audiobookGenres = session.audiobookGenres
|
this.audiobookGenres = session.audiobookGenres
|
||||||
|
|
||||||
this.date = session.date
|
this.date = session.date
|
||||||
@ -64,6 +67,7 @@ class UserListeningSession {
|
|||||||
this.audiobookId = audiobook.id
|
this.audiobookId = audiobook.id
|
||||||
this.audiobookTitle = audiobook.title || ''
|
this.audiobookTitle = audiobook.title || ''
|
||||||
this.audiobookAuthor = audiobook.authorFL || ''
|
this.audiobookAuthor = audiobook.authorFL || ''
|
||||||
|
this.audiobookDuration = audiobook.duration || 0
|
||||||
this.audiobookGenres = [...audiobook.genres]
|
this.audiobookGenres = [...audiobook.genres]
|
||||||
|
|
||||||
this.timeListening = 0
|
this.timeListening = 0
|
||||||
|
@ -182,12 +182,20 @@ module.exports = {
|
|||||||
return Object.values(authorsMap).sort((a, b) => b.count - a.count)
|
return Object.values(authorsMap).sort((a, b) => b.count - a.count)
|
||||||
},
|
},
|
||||||
|
|
||||||
getAudiobooksTotalDuration(audiobooks) {
|
getAudiobookDurationStats(audiobooks) {
|
||||||
|
var sorted = sort(audiobooks).desc(a => a.duration)
|
||||||
|
var top10 = sorted.slice(0, 10).map(ab => ({ title: ab.book.title, duration: ab.duration })).filter(ab => ab.duration > 0)
|
||||||
var totalDuration = 0
|
var totalDuration = 0
|
||||||
|
var numAudioTracks = 0
|
||||||
audiobooks.forEach((ab) => {
|
audiobooks.forEach((ab) => {
|
||||||
totalDuration += ab.duration
|
totalDuration += ab.duration
|
||||||
|
numAudioTracks += ab.tracks.length
|
||||||
})
|
})
|
||||||
return totalDuration
|
return {
|
||||||
|
totalDuration,
|
||||||
|
numAudioTracks,
|
||||||
|
longstAudiobooks: top10
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getAudiobooksTotalSize(audiobooks) {
|
getAudiobooksTotalSize(audiobooks) {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user