mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-03 00:06:46 +01:00
Update:Year stats API endpoint & generate year in review image #2373
This commit is contained in:
parent
7391b4d0ec
commit
2b7122c744
175
client/components/stats/YearInReview.vue
Normal file
175
client/components/stats/YearInReview.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
dataUrl: null,
|
||||
year: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 400
|
||||
canvas.height = 400
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
ctx.fillStyle = '#37383866'
|
||||
ctx.strokeStyle = '#C0C0C0aa'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
if (this.yearStats.booksWithCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
for (let x = 0; x < 4; x++) {
|
||||
for (let y = 0; y < 4; y++) {
|
||||
const coverIndex = index % this.yearStats.booksWithCovers.length
|
||||
let libraryItemId = this.yearStats.booksWithCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
ctx.drawImage(img, 100 * x, 100 * y, 100, 100)
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, 400, 400)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '32px absicons'
|
||||
ctx.fillText('\ue900', 15, 32)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '22px', 'normal', tanColor, '0px', 55, 22)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '14px', 'bold', 'white', '1px', 55, 44)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(10, 65, 185, 80)
|
||||
addText(this.yearStats.numBooksFinished, '32px', 'bold', 'white', '0px', 63, 98)
|
||||
addText('books finished', '14px', 'normal', tanColor, '0px', 63, 120)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.2, d: 1.2, e: 26, f: 90 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(205, 65, 185, 80)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '20px', 'bold', 'white', '0px', 257, 96)
|
||||
addText('spent listening', '14px', 'normal', tanColor, '0px', 257, 117)
|
||||
addIcon('watch_later', 'white', '32px', 218, 105)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(10, 155, 185, 80)
|
||||
addText(this.yearStats.totalListeningSessions, '32px', 'bold', 'white', '0px', 65, 188)
|
||||
addText('sessions', '14px', 'normal', tanColor, '1px', 65, 210)
|
||||
addIcon('headphones', 'white', '32px', 25, 195)
|
||||
|
||||
// Box bottom right
|
||||
createRoundedRect(205, 155, 185, 80)
|
||||
addText(this.yearStats.numBooksListened, '32px', 'bold', 'white', '0px', 258, 188)
|
||||
addText('books listened to', '14px', 'normal', tanColor, '0.65px', 258, 210)
|
||||
addIcon('local_library', 'white', '32px', 220, 195)
|
||||
|
||||
// Text stats
|
||||
const topNarrator = this.yearStats.mostListenedNarrator
|
||||
if (topNarrator) {
|
||||
addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260)
|
||||
addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282)
|
||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302)
|
||||
}
|
||||
|
||||
const topGenre = this.yearStats.topGenres[0]
|
||||
if (topGenre) {
|
||||
addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260)
|
||||
addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282)
|
||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302)
|
||||
}
|
||||
|
||||
const topAuthor = this.yearStats.topAuthors[0]
|
||||
if (topAuthor) {
|
||||
addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335)
|
||||
addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357)
|
||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377)
|
||||
}
|
||||
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
async init() {
|
||||
this.$emit('update:processing', true)
|
||||
let year = new Date().getFullYear()
|
||||
if (new Date().getMonth() < 11) year--
|
||||
this.year = year
|
||||
this.yearStats = await this.$axios.$get(`/api/me/year/${year}/stats`).catch((err) => {
|
||||
console.error('Failed to load stats for year', err)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
return null
|
||||
})
|
||||
await this.initCanvas()
|
||||
this.$emit('update:processing', false)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -62,6 +62,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||
|
||||
<ui-btn small :loading="processingYearInReview" @click.stop="clickShowYearInReview">Year in Review</ui-btn>
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" />
|
||||
</div>
|
||||
</app-settings-content>
|
||||
</div>
|
||||
</template>
|
||||
@ -71,7 +78,9 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
windowWidth: 0,
|
||||
showYearInReview: false,
|
||||
processingYearInReview: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@ -114,6 +123,13 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickShowYearInReview() {
|
||||
if (this.showYearInReview) {
|
||||
this.$refs.yearInReview.refresh()
|
||||
} else {
|
||||
this.showYearInReview = true
|
||||
}
|
||||
},
|
||||
async init() {
|
||||
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
|
@ -2,7 +2,7 @@ const Sequelize = require('sequelize')
|
||||
const Database = require('../../Database')
|
||||
const PlaybackSession = require('../../models/PlaybackSession')
|
||||
const MediaProgress = require('../../models/MediaProgress')
|
||||
const { elapsedPretty } = require('../index')
|
||||
const fsExtra = require('../../libs/fsExtra')
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
@ -18,8 +18,21 @@ module.exports = {
|
||||
createdAt: {
|
||||
[Sequelize.Op.gte]: `${year}-01-01`,
|
||||
[Sequelize.Op.lt]: `${year + 1}-01-01`
|
||||
},
|
||||
timeListening: {
|
||||
[Sequelize.Op.gt]: 5
|
||||
}
|
||||
}
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
attributes: ['id', 'coverPath'],
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'mediaId', 'mediaType']
|
||||
},
|
||||
required: false
|
||||
},
|
||||
order: Database.sequelize.random()
|
||||
})
|
||||
return sessions
|
||||
},
|
||||
@ -42,6 +55,10 @@ module.exports = {
|
||||
},
|
||||
include: {
|
||||
model: Database.bookModel,
|
||||
include: {
|
||||
model: Database.libraryItemModel,
|
||||
attributes: ['id', 'mediaId', 'mediaType']
|
||||
},
|
||||
required: true
|
||||
}
|
||||
})
|
||||
@ -63,8 +80,15 @@ module.exports = {
|
||||
let genreListeningMap = {}
|
||||
let narratorListeningMap = {}
|
||||
let monthListeningMap = {}
|
||||
let bookListeningMap = {}
|
||||
const booksWithCovers = []
|
||||
|
||||
for (const ls of listeningSessions) {
|
||||
// Grab first 16 that have a cover
|
||||
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
|
||||
booksWithCovers.push(ls.mediaItem.libraryItem.id)
|
||||
}
|
||||
|
||||
listeningSessions.forEach((ls) => {
|
||||
const listeningSessionListeningTime = ls.timeListening || 0
|
||||
|
||||
const lsMonth = ls.createdAt.getMonth()
|
||||
@ -75,6 +99,12 @@ module.exports = {
|
||||
if (ls.mediaItemType === 'book') {
|
||||
totalBookListeningTime += listeningSessionListeningTime
|
||||
|
||||
if (ls.displayTitle && !bookListeningMap[ls.displayTitle]) {
|
||||
bookListeningMap[ls.displayTitle] = listeningSessionListeningTime
|
||||
} else if (ls.displayTitle) {
|
||||
bookListeningMap[ls.displayTitle] += listeningSessionListeningTime
|
||||
}
|
||||
|
||||
const authors = ls.mediaMetadata.authors || []
|
||||
authors.forEach((au) => {
|
||||
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||
@ -96,64 +126,54 @@ module.exports = {
|
||||
} else {
|
||||
totalPodcastListeningTime += listeningSessionListeningTime
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
totalListeningTime = Math.round(totalListeningTime)
|
||||
totalBookListeningTime = Math.round(totalBookListeningTime)
|
||||
totalPodcastListeningTime = Math.round(totalPodcastListeningTime)
|
||||
|
||||
let mostListenedAuthor = null
|
||||
for (const authorName in authorListeningMap) {
|
||||
if (!mostListenedAuthor?.time || authorListeningMap[authorName] > mostListenedAuthor.time) {
|
||||
mostListenedAuthor = {
|
||||
time: Math.round(authorListeningMap[authorName]),
|
||||
pretty: elapsedPretty(Math.round(authorListeningMap[authorName])),
|
||||
name: authorName
|
||||
}
|
||||
}
|
||||
}
|
||||
let topAuthors = null
|
||||
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
|
||||
name: authorName,
|
||||
time: Math.round(authorListeningMap[authorName])
|
||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||
|
||||
let mostListenedNarrator = null
|
||||
for (const narrator in narratorListeningMap) {
|
||||
if (!mostListenedNarrator?.time || narratorListeningMap[narrator] > mostListenedNarrator.time) {
|
||||
mostListenedNarrator = {
|
||||
time: Math.round(narratorListeningMap[narrator]),
|
||||
pretty: elapsedPretty(Math.round(narratorListeningMap[narrator])),
|
||||
name: narrator
|
||||
}
|
||||
}
|
||||
}
|
||||
let mostListenedGenre = null
|
||||
for (const genre in genreListeningMap) {
|
||||
if (!mostListenedGenre?.time || genreListeningMap[genre] > mostListenedGenre.time) {
|
||||
mostListenedGenre = {
|
||||
time: Math.round(genreListeningMap[genre]),
|
||||
pretty: elapsedPretty(Math.round(genreListeningMap[genre])),
|
||||
name: genre
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let topGenres = null
|
||||
topGenres = Object.keys(genreListeningMap).map(genre => ({
|
||||
genre,
|
||||
time: Math.round(genreListeningMap[genre])
|
||||
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||
|
||||
let mostListenedMonth = null
|
||||
for (const month in monthListeningMap) {
|
||||
if (!mostListenedMonth?.time || monthListeningMap[month] > mostListenedMonth.time) {
|
||||
mostListenedMonth = {
|
||||
month: Number(month),
|
||||
time: Math.round(monthListeningMap[month]),
|
||||
pretty: elapsedPretty(Math.round(monthListeningMap[month]))
|
||||
time: Math.round(monthListeningMap[month])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bookProgresses = await this.getBookMediaProgressFinishedForYear(userId, year)
|
||||
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
|
||||
|
||||
const numBooksFinished = bookProgresses.length
|
||||
const numBooksFinished = bookProgressesFinished.length
|
||||
let longestAudiobookFinished = null
|
||||
bookProgresses.forEach((mediaProgress) => {
|
||||
bookProgressesFinished.forEach((mediaProgress) => {
|
||||
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
|
||||
longestAudiobookFinished = {
|
||||
id: mediaProgress.mediaItem.id,
|
||||
title: mediaProgress.mediaItem.title,
|
||||
duration: Math.round(mediaProgress.duration),
|
||||
durationPretty: elapsedPretty(Math.round(mediaProgress.duration)),
|
||||
finishedAt: mediaProgress.finishedAt
|
||||
}
|
||||
}
|
||||
@ -162,17 +182,16 @@ module.exports = {
|
||||
return {
|
||||
totalListeningSessions: listeningSessions.length,
|
||||
totalListeningTime,
|
||||
totalListeningTimePretty: elapsedPretty(totalListeningTime),
|
||||
totalBookListeningTime,
|
||||
totalBookListeningTimePretty: elapsedPretty(totalBookListeningTime),
|
||||
totalPodcastListeningTime,
|
||||
totalPodcastListeningTimePretty: elapsedPretty(totalPodcastListeningTime),
|
||||
mostListenedAuthor,
|
||||
topAuthors,
|
||||
topGenres,
|
||||
mostListenedNarrator,
|
||||
mostListenedGenre,
|
||||
mostListenedMonth,
|
||||
numBooksFinished,
|
||||
longestAudiobookFinished
|
||||
numBooksListened: Object.keys(bookListeningMap).length,
|
||||
longestAudiobookFinished,
|
||||
booksWithCovers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user