mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-05-13 01:19:55 +02:00
Add:Year in review banner for user stats page #2373
This commit is contained in:
parent
72fa6b8200
commit
0d644fe0c9
@ -1,24 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center">
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
year: Number,
|
||||||
processing: Boolean
|
processing: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
canvas: null,
|
||||||
dataUrl: null,
|
dataUrl: null,
|
||||||
year: null,
|
|
||||||
yearStats: null
|
yearStats: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async initCanvas() {
|
async initCanvas() {
|
||||||
if (!this.yearStats) return
|
if (!this.yearStats) return
|
||||||
@ -72,7 +82,12 @@ export default {
|
|||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
// Cover image tiles
|
// Cover image tiles
|
||||||
if (this.yearStats.booksWithCovers.length) {
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
let finishedBookCoverImgs = {}
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
let index = 0
|
let index = 0
|
||||||
ctx.globalAlpha = 0.25
|
ctx.globalAlpha = 0.25
|
||||||
ctx.save()
|
ctx.save()
|
||||||
@ -82,8 +97,8 @@ export default {
|
|||||||
ctx.translate(-130, -120)
|
ctx.translate(-130, -120)
|
||||||
for (let x = 0; x < 5; x++) {
|
for (let x = 0; x < 5; x++) {
|
||||||
for (let y = 0; y < 5; y++) {
|
for (let y = 0; y < 5; y++) {
|
||||||
const coverIndex = index % this.yearStats.booksWithCovers.length
|
const coverIndex = index % bookCovers.length
|
||||||
let libraryItemId = this.yearStats.booksWithCovers[coverIndex]
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
index++
|
index++
|
||||||
|
|
||||||
await new Promise((resolve) => {
|
await new Promise((resolve) => {
|
||||||
@ -98,6 +113,14 @@ export default {
|
|||||||
let sy = -(sw - img.height) / 2
|
let sy = -(sw - img.height) / 2
|
||||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||||
resolve()
|
resolve()
|
||||||
|
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||||
|
finishedBookCoverImgs[libraryItemId] = {
|
||||||
|
img,
|
||||||
|
sx,
|
||||||
|
sy,
|
||||||
|
sw
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
img.addEventListener('error', () => {
|
img.addEventListener('error', () => {
|
||||||
resolve()
|
resolve()
|
||||||
@ -141,7 +164,7 @@ export default {
|
|||||||
// Box top right
|
// Box top right
|
||||||
createRoundedRect(410, 100, 340, 160)
|
createRoundedRect(410, 100, 340, 160)
|
||||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||||
addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205)
|
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||||
addIcon('watch_later', 'white', '52px', 440, 180)
|
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||||
|
|
||||||
// Box bottom left
|
// Box bottom left
|
||||||
@ -153,42 +176,98 @@ export default {
|
|||||||
// Box bottom right
|
// Box bottom right
|
||||||
createRoundedRect(410, 280, 340, 160)
|
createRoundedRect(410, 280, 340, 160)
|
||||||
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||||
addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390)
|
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||||
addIcon('local_library', 'white', '52px', 440, 360)
|
addIcon('local_library', 'white', '52px', 440, 360)
|
||||||
|
|
||||||
// Text stats
|
if (!this.variant) {
|
||||||
const topNarrator = this.yearStats.mostListenedNarrator
|
// Text stats
|
||||||
if (topNarrator) {
|
const topNarrator = this.yearStats.mostListenedNarrator
|
||||||
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
if (topNarrator) {
|
||||||
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||||
}
|
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||||
|
}
|
||||||
const topGenre = this.yearStats.topGenres[0]
|
|
||||||
if (topGenre) {
|
const topGenre = this.yearStats.topGenres[0]
|
||||||
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
if (topGenre) {
|
||||||
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||||
}
|
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||||
|
}
|
||||||
const topAuthor = this.yearStats.topAuthors[0]
|
|
||||||
if (topAuthor) {
|
const topAuthor = this.yearStats.topAuthors[0]
|
||||||
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
if (topAuthor) {
|
||||||
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.mostListenedMonth?.time) {
|
||||||
|
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||||
|
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||||
|
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||||
|
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||||
|
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Bottom images
|
||||||
|
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||||
|
if (finishedBookCoverImgs.length > 0) {
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||||
|
let imgToAdd = finishedBookCoverImgs[i]
|
||||||
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
this.dataUrl = canvas.toDataURL('png')
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
},
|
},
|
||||||
refresh() {
|
refresh() {
|
||||||
this.init()
|
this.init()
|
||||||
},
|
},
|
||||||
|
share() {
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'yearinreview.png', { type: blob.type })
|
||||||
|
const shareData = {
|
||||||
|
files: [file]
|
||||||
|
}
|
||||||
|
if (navigator.canShare(shareData)) {
|
||||||
|
navigator
|
||||||
|
.share(shareData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Share success')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to share', error)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Cannot share natively on this device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
let year = new Date().getFullYear()
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
|
||||||
if (new Date().getMonth() < 11) year--
|
|
||||||
this.year = year
|
|
||||||
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${year}`).catch((err) => {
|
|
||||||
console.error('Failed to load stats for year', err)
|
console.error('Failed to load stats for year', err)
|
||||||
this.$toast.error('Failed to load year stats')
|
this.$toast.error('Failed to load year stats')
|
||||||
return null
|
return null
|
||||||
|
134
client/components/stats/YearInReviewBanner.vue
Normal file
134
client/components/stats/YearInReviewBanner.vue
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-4 mb-4">
|
||||||
|
<!-- hack to get icon fonts loaded on init -->
|
||||||
|
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||||
|
<span class="material-icons-outlined">close</span>
|
||||||
|
<span class="abs-icons icon-audiobookshelf" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||||
|
<div class="hidden md:block flex-grow" />
|
||||||
|
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : 'See Year in Review' }}</ui-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your year in review -->
|
||||||
|
<div v-if="showYearInReview">
|
||||||
|
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||||
|
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-lg font-semibold">Your Year <span class="hidden md:inline-block">in Review </span>({{ yearInReviewVariant + 1 }})</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||||
|
|
||||||
|
<!-- your year in review short -->
|
||||||
|
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||||
|
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- your server in review -->
|
||||||
|
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||||
|
<div class="flex items-center justify-center mb-2">
|
||||||
|
<!-- previous button -->
|
||||||
|
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||||
|
<span class="material-icons-outlined text-lg px-1 sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||||
|
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- share button -->
|
||||||
|
<ui-btn v-if="showShareButton" small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||||
|
|
||||||
|
<div class="flex-grow" />
|
||||||
|
<p class="text-lg font-semibold">Server <span class="hidden md:inline-block">Year in Review </span>({{ yearInReviewServerVariant + 1 }})</p>
|
||||||
|
<div class="flex-grow" />
|
||||||
|
|
||||||
|
<!-- refresh button -->
|
||||||
|
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||||
|
<span class="hidden sm:inline-block">Refresh</span>
|
||||||
|
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||||
|
</ui-btn>
|
||||||
|
<!-- next button -->
|
||||||
|
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||||
|
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||||
|
<span class="material-icons-outlined text-lg px-1 sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||||
|
</ui-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showYearInReview: false,
|
||||||
|
yearInReviewYear: 0,
|
||||||
|
yearInReviewVariant: 0,
|
||||||
|
yearInReviewServerVariant: 0,
|
||||||
|
processingYearInReview: false,
|
||||||
|
processingYearInReviewShort: false,
|
||||||
|
processingYearInReviewServer: false,
|
||||||
|
showShareButton: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
isAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
shareYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.share()
|
||||||
|
},
|
||||||
|
shareYearInReview() {
|
||||||
|
this.$refs.yearInReview.share()
|
||||||
|
},
|
||||||
|
refreshYearInReviewServer() {
|
||||||
|
this.$refs.yearInReviewServer.refresh()
|
||||||
|
},
|
||||||
|
refreshYearInReview() {
|
||||||
|
this.$refs.yearInReview.refresh()
|
||||||
|
this.$refs.yearInReviewShort.refresh()
|
||||||
|
},
|
||||||
|
clickShowYearInReview() {
|
||||||
|
this.showYearInReview = !this.showYearInReview
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeMount() {
|
||||||
|
this.yearInReviewYear = new Date().getFullYear()
|
||||||
|
// When not December show previous year
|
||||||
|
if (new Date().getMonth() < 11) {
|
||||||
|
this.yearInReviewYear--
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (typeof navigator.share !== 'undefined' && navigator.share) {
|
||||||
|
this.showShareButton = true
|
||||||
|
} else {
|
||||||
|
console.warn('Navigator.share not supported')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,24 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center">
|
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||||
<widgets-loading-spinner />
|
<widgets-loading-spinner />
|
||||||
</div>
|
</div>
|
||||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
processing: Boolean
|
variant: {
|
||||||
|
type: Number,
|
||||||
|
default: 0
|
||||||
|
},
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
canvas: null,
|
||||||
dataUrl: null,
|
dataUrl: null,
|
||||||
year: null,
|
|
||||||
yearStats: null
|
yearStats: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
watch: {
|
||||||
|
variant() {
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async initCanvas() {
|
async initCanvas() {
|
||||||
if (!this.yearStats) return
|
if (!this.yearStats) return
|
||||||
@ -61,12 +71,6 @@ export default {
|
|||||||
ctx.fillText(text, x, y)
|
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
|
// Bg color
|
||||||
ctx.fillStyle = '#232323'
|
ctx.fillStyle = '#232323'
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
@ -168,28 +172,81 @@ export default {
|
|||||||
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom images
|
if (!this.variant) {
|
||||||
imgsToAdd = Object.values(imgsToAdd)
|
// Bottom images
|
||||||
if (imgsToAdd.length >= 5) {
|
imgsToAdd = Object.values(imgsToAdd)
|
||||||
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
if (imgsToAdd.length > 0) {
|
||||||
|
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||||
let imgToAdd = imgsToAdd[i]
|
let imgToAdd = imgsToAdd[i]
|
||||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 1) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topNarrators.length) {
|
||||||
|
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||||
|
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (this.variant === 2) {
|
||||||
|
// Text stats
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
if (this.yearStats.topAuthors.length) {
|
||||||
|
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||||
|
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.yearStats.topGenres.length) {
|
||||||
|
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||||
|
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||||
|
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.canvas = canvas
|
||||||
this.dataUrl = canvas.toDataURL('png')
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
},
|
},
|
||||||
|
share() {
|
||||||
|
this.canvas.toBlob((blob) => {
|
||||||
|
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
|
||||||
|
const shareData = {
|
||||||
|
files: [file]
|
||||||
|
}
|
||||||
|
if (navigator.canShare(shareData)) {
|
||||||
|
navigator
|
||||||
|
.share(shareData)
|
||||||
|
.then(() => {
|
||||||
|
console.log('Share success')
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Failed to share', error)
|
||||||
|
this.$toast.error('Failed to share: ' + error.message)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.$toast.error('Cannot share natively on this device')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
refresh() {
|
refresh() {
|
||||||
this.init()
|
this.init()
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.$emit('update:processing', true)
|
this.$emit('update:processing', true)
|
||||||
let year = new Date().getFullYear()
|
this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
|
||||||
if (new Date().getMonth() < 11) year--
|
|
||||||
this.year = year
|
|
||||||
this.yearStats = await this.$axios.$get(`/api/stats/year/${year}`).catch((err) => {
|
|
||||||
console.error('Failed to load stats for year', err)
|
console.error('Failed to load stats for year', err)
|
||||||
this.$toast.error('Failed to load year stats')
|
this.$toast.error('Failed to load year stats')
|
||||||
return null
|
return null
|
||||||
|
169
client/components/stats/YearInReviewShort.vue
Normal file
169
client/components/stats/YearInReviewShort.vue
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||||
|
<widgets-loading-spinner />
|
||||||
|
</div>
|
||||||
|
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
processing: Boolean,
|
||||||
|
year: Number
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
dataUrl: null,
|
||||||
|
yearStats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async initCanvas() {
|
||||||
|
if (!this.yearStats) return
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 600
|
||||||
|
canvas.height = 200
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const createRoundedRect = (x, y, w, h) => {
|
||||||
|
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||||
|
grd1.addColorStop(0, '#44444455')
|
||||||
|
grd1.addColorStop(1, '#ffffff11')
|
||||||
|
ctx.fillStyle = grd1
|
||||||
|
ctx.strokeStyle = '#C0C0C088'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(x, y, w, h, [20])
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
|
||||||
|
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||||
|
ctx.fillStyle = color
|
||||||
|
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||||
|
ctx.letterSpacing = letterSpacing
|
||||||
|
|
||||||
|
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||||
|
if (maxWidth) {
|
||||||
|
let txtWidth = ctx.measureText(text).width
|
||||||
|
while (txtWidth > maxWidth) {
|
||||||
|
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||||
|
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||||
|
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||||
|
text += '...'
|
||||||
|
txtWidth = ctx.measureText(text).width
|
||||||
|
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||||
|
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||||
|
|
||||||
|
if (bookCovers.length) {
|
||||||
|
let index = 0
|
||||||
|
ctx.globalAlpha = 0.25
|
||||||
|
ctx.save()
|
||||||
|
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||||
|
ctx.rotate((-Math.PI / 180) * 25)
|
||||||
|
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||||
|
ctx.translate(-10, -90)
|
||||||
|
for (let x = 0; x < 4; x++) {
|
||||||
|
for (let y = 0; y < 3; y++) {
|
||||||
|
const coverIndex = index % bookCovers.length
|
||||||
|
let libraryItemId = bookCovers[coverIndex]
|
||||||
|
index++
|
||||||
|
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
const img = new Image()
|
||||||
|
img.crossOrigin = 'anonymous'
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
let sw = img.width
|
||||||
|
if (img.width > img.height) {
|
||||||
|
sw = img.height
|
||||||
|
}
|
||||||
|
let sx = -(sw - img.width) / 2
|
||||||
|
let sy = -(sw - img.height) / 2
|
||||||
|
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.addEventListener('error', () => {
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
|
||||||
|
// Create gradient
|
||||||
|
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
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 = '42px absicons'
|
||||||
|
ctx.fillText('\ue900', 15, 36)
|
||||||
|
|
||||||
|
// Top text
|
||||||
|
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||||
|
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||||
|
|
||||||
|
// Top left box
|
||||||
|
createRoundedRect(15, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||||
|
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||||
|
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.5, d: 1.5, e: 55, f: 115 })
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.fill(readIconPath)
|
||||||
|
|
||||||
|
createRoundedRect(305, 75, 280, 110)
|
||||||
|
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||||
|
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||||
|
addIcon('local_library', 'white', '42px', 345, 130)
|
||||||
|
|
||||||
|
this.dataUrl = canvas.toDataURL('png')
|
||||||
|
},
|
||||||
|
refresh() {
|
||||||
|
this.init()
|
||||||
|
},
|
||||||
|
async init() {
|
||||||
|
this.$emit('update:processing', true)
|
||||||
|
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).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>
|
@ -1,6 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<app-settings-content :header-text="$strings.HeaderYourStats">
|
<!-- Year in review banner shown at the top in December and January -->
|
||||||
|
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
|
||||||
|
|
||||||
|
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
|
||||||
<div class="flex justify-center">
|
<div class="flex justify-center">
|
||||||
<div class="flex p-2">
|
<div class="flex p-2">
|
||||||
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
|
||||||
@ -62,16 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
|
||||||
|
|
||||||
<ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : '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" />
|
|
||||||
|
|
||||||
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" />
|
|
||||||
</div>
|
|
||||||
</app-settings-content>
|
</app-settings-content>
|
||||||
|
|
||||||
|
<!-- Year in review banner shown at the bottom Feb - Nov -->
|
||||||
|
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -81,9 +78,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
listeningStats: null,
|
listeningStats: null,
|
||||||
windowWidth: 0,
|
windowWidth: 0,
|
||||||
showYearInReview: false,
|
showYearInReviewBanner: false
|
||||||
processingYearInReview: false,
|
|
||||||
processingYearInReviewAlt: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -126,22 +121,17 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickShowYearInReview() {
|
|
||||||
if (this.showYearInReview) {
|
|
||||||
this.$refs.yearInReview.refresh()
|
|
||||||
|
|
||||||
if (this.$refs.yearInReviewAlt) {
|
|
||||||
this.$refs.yearInReviewAlt.refresh()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.showYearInReview = true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async init() {
|
async init() {
|
||||||
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 []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let month = new Date().getMonth()
|
||||||
|
// January and December show year in review banner
|
||||||
|
if (month === 11 || month === 0) {
|
||||||
|
this.showYearInReviewBanner = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -88,12 +88,53 @@ module.exports = {
|
|||||||
|
|
||||||
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
|
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
|
||||||
|
|
||||||
|
let authorListeningMap = {}
|
||||||
|
let narratorListeningMap = {}
|
||||||
|
let genreListeningMap = {}
|
||||||
|
|
||||||
const listeningSessions = await this.getListeningSessionsForYear(year)
|
const listeningSessions = await this.getListeningSessionsForYear(year)
|
||||||
let totalListeningTime = 0
|
let totalListeningTime = 0
|
||||||
for (const listeningSession of listeningSessions) {
|
for (const ls of listeningSessions) {
|
||||||
totalListeningTime += (listeningSession.timeListening || 0)
|
totalListeningTime += (ls.timeListening || 0)
|
||||||
|
|
||||||
|
const authors = ls.mediaMetadata.authors || []
|
||||||
|
authors.forEach((au) => {
|
||||||
|
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
|
||||||
|
authorListeningMap[au.name] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const narrators = ls.mediaMetadata.narrators || []
|
||||||
|
narrators.forEach((narrator) => {
|
||||||
|
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
|
||||||
|
narratorListeningMap[narrator] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter out bad genres like "audiobook" and "audio book"
|
||||||
|
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
|
||||||
|
genres.forEach((genre) => {
|
||||||
|
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
|
||||||
|
genreListeningMap[genre] += (ls.timeListening || 0)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 topNarrators = null
|
||||||
|
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
|
||||||
|
name: narratorName,
|
||||||
|
time: Math.round(narratorListeningMap[narratorName])
|
||||||
|
})).sort((a, b) => b.time - a.time).slice(0, 3)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
// Stats for total books, size and duration for everything added this year or earlier
|
// Stats for total books, size and duration for everything added this year or earlier
|
||||||
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
|
||||||
replacements: {
|
replacements: {
|
||||||
@ -112,7 +153,10 @@ module.exports = {
|
|||||||
totalBooksSize: totalStatResults?.totalSize || 0,
|
totalBooksSize: totalStatResults?.totalSize || 0,
|
||||||
totalBooksDuration: totalStatResults?.totalDuration || 0,
|
totalBooksDuration: totalStatResults?.totalDuration || 0,
|
||||||
totalListeningTime,
|
totalListeningTime,
|
||||||
numBooks: totalStatResults?.totalItems || 0
|
numBooks: totalStatResults?.totalItems || 0,
|
||||||
|
topAuthors,
|
||||||
|
topNarrators,
|
||||||
|
topGenres
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -52,12 +52,14 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
model: Database.bookModel,
|
model: Database.bookModel,
|
||||||
|
attributes: ['id', 'title', 'coverPath'],
|
||||||
include: {
|
include: {
|
||||||
model: Database.libraryItemModel,
|
model: Database.libraryItemModel,
|
||||||
attributes: ['id', 'mediaId', 'mediaType']
|
attributes: ['id', 'mediaId', 'mediaType']
|
||||||
},
|
},
|
||||||
required: true
|
required: true
|
||||||
}
|
},
|
||||||
|
order: Database.sequelize.random()
|
||||||
})
|
})
|
||||||
return progresses
|
return progresses
|
||||||
},
|
},
|
||||||
@ -69,6 +71,7 @@ module.exports = {
|
|||||||
async getStatsForYear(user, year) {
|
async getStatsForYear(user, year) {
|
||||||
const userId = user.id
|
const userId = user.id
|
||||||
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
|
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
|
||||||
|
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
|
||||||
|
|
||||||
let totalBookListeningTime = 0
|
let totalBookListeningTime = 0
|
||||||
let totalPodcastListeningTime = 0
|
let totalPodcastListeningTime = 0
|
||||||
@ -79,11 +82,33 @@ module.exports = {
|
|||||||
let narratorListeningMap = {}
|
let narratorListeningMap = {}
|
||||||
let monthListeningMap = {}
|
let monthListeningMap = {}
|
||||||
let bookListeningMap = {}
|
let bookListeningMap = {}
|
||||||
const booksWithCovers = []
|
|
||||||
|
|
||||||
|
const booksWithCovers = []
|
||||||
|
const finishedBooksWithCovers = []
|
||||||
|
|
||||||
|
// Get finished book stats
|
||||||
|
const numBooksFinished = bookProgressesFinished.length
|
||||||
|
let longestAudiobookFinished = null
|
||||||
|
for (const mediaProgress of bookProgressesFinished) {
|
||||||
|
// Grab first 5 that have a cover
|
||||||
|
if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) {
|
||||||
|
finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
|
||||||
|
longestAudiobookFinished = {
|
||||||
|
id: mediaProgress.mediaItem.id,
|
||||||
|
title: mediaProgress.mediaItem.title,
|
||||||
|
duration: Math.round(mediaProgress.duration),
|
||||||
|
finishedAt: mediaProgress.finishedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get listening session stats
|
||||||
for (const ls of listeningSessions) {
|
for (const ls of listeningSessions) {
|
||||||
// Grab first 25 that have a cover
|
// Grab first 25 that have a cover
|
||||||
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
|
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
|
||||||
booksWithCovers.push(ls.mediaItem.libraryItem.id)
|
booksWithCovers.push(ls.mediaItem.libraryItem.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -162,21 +187,6 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
|
|
||||||
|
|
||||||
const numBooksFinished = bookProgressesFinished.length
|
|
||||||
let longestAudiobookFinished = null
|
|
||||||
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),
|
|
||||||
finishedAt: mediaProgress.finishedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalListeningSessions: listeningSessions.length,
|
totalListeningSessions: listeningSessions.length,
|
||||||
totalListeningTime,
|
totalListeningTime,
|
||||||
@ -189,7 +199,8 @@ module.exports = {
|
|||||||
numBooksFinished,
|
numBooksFinished,
|
||||||
numBooksListened: Object.keys(bookListeningMap).length,
|
numBooksListened: Object.keys(bookListeningMap).length,
|
||||||
longestAudiobookFinished,
|
longestAudiobookFinished,
|
||||||
booksWithCovers
|
booksWithCovers,
|
||||||
|
finishedBooksWithCovers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user