mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-14 18:08:25 +01: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>
|
||||
|
||||
<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>
|
||||
</nuxt-link>
|
||||
|
||||
|
@ -3,29 +3,10 @@
|
||||
<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>
|
||||
</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>
|
||||
<div v-show="routeName === 'config'" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</nuxt-link>
|
||||
<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 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'">
|
||||
<p>{{ route.title }}</p>
|
||||
<div v-show="routeName === route.iod" class="h-full w-0.5 bg-yellow-400 absolute top-0 left-0" />
|
||||
</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' }">
|
||||
@ -47,6 +28,57 @@ export default {
|
||||
}
|
||||
},
|
||||
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() {
|
||||
var classes = []
|
||||
if (this.drawerOpen) classes.push('translate-x-0')
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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="absolute top-0 left-0">
|
||||
<template v-for="lbl in yAxisLabels">
|
||||
|
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap justify-between mt-6">
|
||||
<div class="flex p-2">
|
||||
<div class="flex flex-wrap justify-center mt-6">
|
||||
<div class="flex px-2">
|
||||
<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" />
|
||||
</svg>
|
||||
@ -9,20 +9,8 @@
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Books in Library</p>
|
||||
</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>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalAudiobookHours }}</p>
|
||||
@ -30,7 +18,7 @@
|
||||
</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">
|
||||
<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>
|
||||
@ -40,11 +28,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex p-2">
|
||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
||||
<div class="flex px-4">
|
||||
<span class="material-icons-outlined text-6xl pt-1">insert_drive_file</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</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">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>
|
||||
@ -53,10 +49,6 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
listeningStats: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
libraryStats: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
@ -75,11 +67,8 @@ export default {
|
||||
totalAuthors() {
|
||||
return this.libraryStats ? this.libraryStats.totalAuthors : 0
|
||||
},
|
||||
userAudiobooks() {
|
||||
return Object.values(this.user.audiobooks || {})
|
||||
},
|
||||
userAudiobooksRead() {
|
||||
return this.userAudiobooks.filter((ab) => !!ab.isRead)
|
||||
numAudioTracks() {
|
||||
return this.libraryStats ? this.libraryStats.numAudioTracks : 0
|
||||
},
|
||||
totalAudiobookDuration() {
|
||||
return this.libraryStats ? this.libraryStats.totalDuration : 0
|
||||
@ -88,9 +77,15 @@ export default {
|
||||
var totalHours = Math.round(this.totalAudiobookDuration / (60 * 60))
|
||||
return totalHours
|
||||
},
|
||||
totalMinutesListening() {
|
||||
if (!this.listeningStats) return 0
|
||||
return Math.round(this.listeningStats.totalTime / 60)
|
||||
totalSizePretty() {
|
||||
var totalSize = this.libraryStats ? this.libraryStats.totalSize : 0
|
||||
return this.$bytesPretty(totalSize, 1)
|
||||
},
|
||||
totalSizeNum() {
|
||||
return this.totalSizePretty.split(' ')[0]
|
||||
},
|
||||
totalSizeMod() {
|
||||
return this.totalSizePretty.split(' ')[1]
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
@ -21,7 +21,7 @@
|
||||
<td>
|
||||
<div class="flex items-center">
|
||||
<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>
|
||||
</td>
|
||||
<td class="text-sm">{{ user.type }}</td>
|
||||
|
@ -14,9 +14,12 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
asyncData({ store, redirect, route }) {
|
||||
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() {
|
||||
@ -38,7 +41,7 @@ export default {
|
||||
currentPage() {
|
||||
if (!this.$route.name) return 'Settings'
|
||||
var routeName = this.$route.name.split('-')
|
||||
if (routeName.length > 0) return routeName[1]
|
||||
if (routeName.length > 0) return routeName.slice(1).join('-')
|
||||
return 'Settings'
|
||||
}
|
||||
},
|
||||
@ -72,7 +75,7 @@ export default {
|
||||
width: 900px;
|
||||
max-width: calc(100% - 176px);
|
||||
}
|
||||
.configContent.page-stats {
|
||||
.configContent.page-library-stats {
|
||||
width: 1200px;
|
||||
}
|
||||
@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>
|
||||
<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="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Top 5 Genres</h1>
|
||||
<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 class="flex p-2">
|
||||
<span class="material-icons-outlined" style="font-size: 4.1rem">event</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalDaysListened }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Days Listened</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-80 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Top 10 Authors</h1>
|
||||
<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 class="flex p-2">
|
||||
<span class="material-icons-outlined" style="font-size: 4.1rem">watch_later</span>
|
||||
<div class="px-1">
|
||||
<p class="text-4xl md:text-5xl font-bold">{{ totalMinutesListening }}</p>
|
||||
<p class="font-book text-xs md:text-sm text-white text-opacity-80">Minutes Listening</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<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>
|
||||
</template>
|
||||
@ -58,27 +74,31 @@ export default {
|
||||
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() {
|
||||
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: {
|
||||
@ -88,7 +108,6 @@ export default {
|
||||
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)
|
||||
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
|
@ -13,6 +13,11 @@ export const getters = {
|
||||
getCurrentLibrary: state => {
|
||||
return state.libraries.find(lib => lib.id === state.currentLibraryId)
|
||||
},
|
||||
getCurrentLibraryName: (state, getters) => {
|
||||
var currentLibrary = getters.getCurrentLibrary
|
||||
if (!currentLibrary) return ''
|
||||
return currentLibrary.name
|
||||
},
|
||||
getSortedLibraries: state => () => {
|
||||
return state.libraries.map(lib => ({ ...lib })).sort((a, b) => a.displayOrder - b.displayOrder)
|
||||
}
|
||||
|
@ -458,14 +458,15 @@ class ApiController {
|
||||
books: {},
|
||||
days: {},
|
||||
dayOfWeek: {},
|
||||
today: 0
|
||||
today: 0,
|
||||
recentSessions: listeningSessions.slice(0, 10)
|
||||
}
|
||||
listeningSessions.forEach((s) => {
|
||||
if (s.dayOfWeek) {
|
||||
if (!listeningStats.dayOfWeek[s.dayOfWeek]) listeningStats.dayOfWeek[s.dayOfWeek] = 0
|
||||
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
|
||||
listeningStats.days[s.date] += s.timeListening
|
||||
|
||||
@ -473,8 +474,17 @@ class ApiController {
|
||||
listeningStats.today += s.timeListening
|
||||
}
|
||||
}
|
||||
if (!listeningStats.books[s.audiobookId]) listeningStats.books[s.audiobookId] = 0
|
||||
listeningStats.books[s.audiobookId] += s.timeListening
|
||||
if (!listeningStats.books[s.audiobookId]) {
|
||||
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
|
||||
})
|
||||
|
@ -375,11 +375,14 @@ class LibraryController {
|
||||
|
||||
var authorsWithCount = libraryHelpers.getAuthorsWithCount(audiobooksInLibrary)
|
||||
var genresWithCount = libraryHelpers.getGenresWithCount(audiobooksInLibrary)
|
||||
var abDurationStats = libraryHelpers.getAudiobookDurationStats(audiobooksInLibrary)
|
||||
var stats = {
|
||||
totalBooks: audiobooksInLibrary.length,
|
||||
totalAuthors: Object.keys(authorsWithCount).length,
|
||||
totalGenres: Object.keys(genresWithCount).length,
|
||||
totalDuration: libraryHelpers.getAudiobooksTotalDuration(audiobooksInLibrary),
|
||||
totalDuration: abDurationStats.totalDuration,
|
||||
longestAudiobooks: abDurationStats.longstAudiobooks,
|
||||
numAudioTracks: abDurationStats.numAudioTracks,
|
||||
totalSize: libraryHelpers.getAudiobooksTotalSize(audiobooksInLibrary),
|
||||
authorsWithCount,
|
||||
genresWithCount
|
||||
|
@ -10,6 +10,7 @@ class UserListeningSession {
|
||||
this.audiobookId = null
|
||||
this.audiobookTitle = null
|
||||
this.audiobookAuthor = null
|
||||
this.audiobookDuration = 0
|
||||
this.audiobookGenres = []
|
||||
|
||||
this.date = null
|
||||
@ -32,6 +33,7 @@ class UserListeningSession {
|
||||
audiobookId: this.audiobookId,
|
||||
audiobookTitle: this.audiobookTitle,
|
||||
audiobookAuthor: this.audiobookAuthor,
|
||||
audiobookDuration: this.audiobookDuration,
|
||||
audiobookGenres: [...this.audiobookGenres],
|
||||
date: this.date,
|
||||
dayOfWeek: this.dayOfWeek,
|
||||
@ -48,6 +50,7 @@ class UserListeningSession {
|
||||
this.audiobookId = session.audiobookId
|
||||
this.audiobookTitle = session.audiobookTitle
|
||||
this.audiobookAuthor = session.audiobookAuthor
|
||||
this.audiobookDuration = session.audiobookDuration || 0
|
||||
this.audiobookGenres = session.audiobookGenres
|
||||
|
||||
this.date = session.date
|
||||
@ -64,6 +67,7 @@ class UserListeningSession {
|
||||
this.audiobookId = audiobook.id
|
||||
this.audiobookTitle = audiobook.title || ''
|
||||
this.audiobookAuthor = audiobook.authorFL || ''
|
||||
this.audiobookDuration = audiobook.duration || 0
|
||||
this.audiobookGenres = [...audiobook.genres]
|
||||
|
||||
this.timeListening = 0
|
||||
|
@ -182,12 +182,20 @@ module.exports = {
|
||||
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 numAudioTracks = 0
|
||||
audiobooks.forEach((ab) => {
|
||||
totalDuration += ab.duration
|
||||
numAudioTracks += ab.tracks.length
|
||||
})
|
||||
return totalDuration
|
||||
return {
|
||||
totalDuration,
|
||||
numAudioTracks,
|
||||
longstAudiobooks: top10
|
||||
}
|
||||
},
|
||||
|
||||
getAudiobooksTotalSize(audiobooks) {
|
||||
|
Loading…
Reference in New Issue
Block a user