mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add:Year in review card for server stats #2373
This commit is contained in:
		
							parent
							
								
									68d36522b1
								
							
						
					
					
						commit
						2738402aac
					
				@ -24,12 +24,15 @@ export default {
 | 
				
			|||||||
      if (!this.yearStats) return
 | 
					      if (!this.yearStats) return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const canvas = document.createElement('canvas')
 | 
					      const canvas = document.createElement('canvas')
 | 
				
			||||||
      canvas.width = 400
 | 
					      canvas.width = 800
 | 
				
			||||||
      canvas.height = 400
 | 
					      canvas.height = 800
 | 
				
			||||||
      const ctx = canvas.getContext('2d')
 | 
					      const ctx = canvas.getContext('2d')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const createRoundedRect = (x, y, w, h) => {
 | 
					      const createRoundedRect = (x, y, w, h) => {
 | 
				
			||||||
        ctx.fillStyle = '#37383866'
 | 
					        const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
 | 
				
			||||||
 | 
					        grd1.addColorStop(0, '#44444466')
 | 
				
			||||||
 | 
					        grd1.addColorStop(1, '#ffffff22')
 | 
				
			||||||
 | 
					        ctx.fillStyle = grd1
 | 
				
			||||||
        ctx.strokeStyle = '#C0C0C0aa'
 | 
					        ctx.strokeStyle = '#C0C0C0aa'
 | 
				
			||||||
        ctx.beginPath()
 | 
					        ctx.beginPath()
 | 
				
			||||||
        ctx.roundRect(x, y, w, h, [20])
 | 
					        ctx.roundRect(x, y, w, h, [20])
 | 
				
			||||||
@ -72,8 +75,13 @@ export default {
 | 
				
			|||||||
      if (this.yearStats.booksWithCovers.length) {
 | 
					      if (this.yearStats.booksWithCovers.length) {
 | 
				
			||||||
        let index = 0
 | 
					        let index = 0
 | 
				
			||||||
        ctx.globalAlpha = 0.25
 | 
					        ctx.globalAlpha = 0.25
 | 
				
			||||||
        for (let x = 0; x < 4; x++) {
 | 
					        ctx.save()
 | 
				
			||||||
          for (let y = 0; y < 4; y++) {
 | 
					        ctx.translate(canvas.width / 2, canvas.height / 2)
 | 
				
			||||||
 | 
					        ctx.rotate((-Math.PI / 180) * 25)
 | 
				
			||||||
 | 
					        ctx.translate(-canvas.width / 2, -canvas.height / 2)
 | 
				
			||||||
 | 
					        ctx.translate(-130, -120)
 | 
				
			||||||
 | 
					        for (let x = 0; x < 5; x++) {
 | 
				
			||||||
 | 
					          for (let y = 0; y < 5; y++) {
 | 
				
			||||||
            const coverIndex = index % this.yearStats.booksWithCovers.length
 | 
					            const coverIndex = index % this.yearStats.booksWithCovers.length
 | 
				
			||||||
            let libraryItemId = this.yearStats.booksWithCovers[coverIndex]
 | 
					            let libraryItemId = this.yearStats.booksWithCovers[coverIndex]
 | 
				
			||||||
            index++
 | 
					            index++
 | 
				
			||||||
@ -82,7 +90,13 @@ export default {
 | 
				
			|||||||
              const img = new Image()
 | 
					              const img = new Image()
 | 
				
			||||||
              img.crossOrigin = 'anonymous'
 | 
					              img.crossOrigin = 'anonymous'
 | 
				
			||||||
              img.addEventListener('load', () => {
 | 
					              img.addEventListener('load', () => {
 | 
				
			||||||
                ctx.drawImage(img, 100 * x, 100 * y, 100, 100)
 | 
					                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, 215 * x, 215 * y, 215, 215)
 | 
				
			||||||
                resolve()
 | 
					                resolve()
 | 
				
			||||||
              })
 | 
					              })
 | 
				
			||||||
              img.addEventListener('error', () => {
 | 
					              img.addEventListener('error', () => {
 | 
				
			||||||
@ -92,13 +106,14 @@ export default {
 | 
				
			|||||||
            })
 | 
					            })
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					        ctx.restore()
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      ctx.globalAlpha = 1
 | 
					      ctx.globalAlpha = 1
 | 
				
			||||||
      ctx.textBaseline = 'middle'
 | 
					      ctx.textBaseline = 'middle'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Create gradient
 | 
					      // Create gradient
 | 
				
			||||||
      const grd1 = ctx.createLinearGradient(0, 0, 400, 400)
 | 
					      const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
 | 
				
			||||||
      grd1.addColorStop(0, '#000000aa')
 | 
					      grd1.addColorStop(0, '#000000aa')
 | 
				
			||||||
      grd1.addColorStop(1, '#cd9d49aa')
 | 
					      grd1.addColorStop(1, '#cd9d49aa')
 | 
				
			||||||
      ctx.fillStyle = grd1
 | 
					      ctx.fillStyle = grd1
 | 
				
			||||||
@ -107,60 +122,60 @@ export default {
 | 
				
			|||||||
      // Top Abs icon
 | 
					      // Top Abs icon
 | 
				
			||||||
      let tanColor = '#ffdb70'
 | 
					      let tanColor = '#ffdb70'
 | 
				
			||||||
      ctx.fillStyle = tanColor
 | 
					      ctx.fillStyle = tanColor
 | 
				
			||||||
      ctx.font = '32px absicons'
 | 
					      ctx.font = '42px absicons'
 | 
				
			||||||
      ctx.fillText('\ue900', 15, 32)
 | 
					      ctx.fillText('\ue900', 15, 36)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Top text
 | 
					      // Top text
 | 
				
			||||||
      addText('audiobookshelf', '22px', 'normal', tanColor, '0px', 55, 22)
 | 
					      addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
 | 
				
			||||||
      addText(`${this.year} YEAR IN REVIEW`, '14px', 'bold', 'white', '1px', 55, 44)
 | 
					      addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Top left box
 | 
					      // Top left box
 | 
				
			||||||
      createRoundedRect(10, 65, 185, 80)
 | 
					      createRoundedRect(50, 100, 340, 160)
 | 
				
			||||||
      addText(this.yearStats.numBooksFinished, '32px', 'bold', 'white', '0px', 63, 98)
 | 
					      addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
 | 
				
			||||||
      addText('books finished', '14px', 'normal', tanColor, '0px', 63, 120)
 | 
					      addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
 | 
				
			||||||
      const readIconPath = new Path2D()
 | 
					      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 })
 | 
					      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: 2, d: 2, e: 100, f: 160 })
 | 
				
			||||||
      ctx.fillStyle = '#ffffff'
 | 
					      ctx.fillStyle = '#ffffff'
 | 
				
			||||||
      ctx.fill(readIconPath)
 | 
					      ctx.fill(readIconPath)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Box top right
 | 
					      // Box top right
 | 
				
			||||||
      createRoundedRect(205, 65, 185, 80)
 | 
					      createRoundedRect(410, 100, 340, 160)
 | 
				
			||||||
      addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '20px', 'bold', 'white', '0px', 257, 96)
 | 
					      addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
 | 
				
			||||||
      addText('spent listening', '14px', 'normal', tanColor, '0px', 257, 117)
 | 
					      addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205)
 | 
				
			||||||
      addIcon('watch_later', 'white', '32px', 218, 105)
 | 
					      addIcon('watch_later', 'white', '52px', 440, 180)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Box bottom left
 | 
					      // Box bottom left
 | 
				
			||||||
      createRoundedRect(10, 155, 185, 80)
 | 
					      createRoundedRect(50, 280, 340, 160)
 | 
				
			||||||
      addText(this.yearStats.totalListeningSessions, '32px', 'bold', 'white', '0px', 65, 188)
 | 
					      addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
 | 
				
			||||||
      addText('sessions', '14px', 'normal', tanColor, '1px', 65, 210)
 | 
					      addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
 | 
				
			||||||
      addIcon('headphones', 'white', '32px', 25, 195)
 | 
					      addIcon('headphones', 'white', '52px', 95, 360)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Box bottom right
 | 
					      // Box bottom right
 | 
				
			||||||
      createRoundedRect(205, 155, 185, 80)
 | 
					      createRoundedRect(410, 280, 340, 160)
 | 
				
			||||||
      addText(this.yearStats.numBooksListened, '32px', 'bold', 'white', '0px', 258, 188)
 | 
					      addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
 | 
				
			||||||
      addText('books listened to', '14px', 'normal', tanColor, '0.65px', 258, 210)
 | 
					      addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390)
 | 
				
			||||||
      addIcon('local_library', 'white', '32px', 220, 195)
 | 
					      addIcon('local_library', 'white', '52px', 440, 360)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      // Text stats
 | 
					      // Text stats
 | 
				
			||||||
      const topNarrator = this.yearStats.mostListenedNarrator
 | 
					      const topNarrator = this.yearStats.mostListenedNarrator
 | 
				
			||||||
      if (topNarrator) {
 | 
					      if (topNarrator) {
 | 
				
			||||||
        addText('TOP NARRATOR', '12px', 'normal', tanColor, '1px', 20, 260)
 | 
					        addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
 | 
				
			||||||
        addText(topNarrator.name, '18px', 'bolder', 'white', '0px', 20, 282, 180)
 | 
					        addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
 | 
				
			||||||
        addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '14px', 'lighter', 'white', '1px', 20, 302)
 | 
					        addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const topGenre = this.yearStats.topGenres[0]
 | 
					      const topGenre = this.yearStats.topGenres[0]
 | 
				
			||||||
      if (topGenre) {
 | 
					      if (topGenre) {
 | 
				
			||||||
        addText('TOP GENRE', '12px', 'normal', tanColor, '1px', 215, 260)
 | 
					        addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
 | 
				
			||||||
        addText(topGenre.genre, '18px', 'bolder', 'white', '0px', 215, 282, 180)
 | 
					        addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
 | 
				
			||||||
        addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '14px', 'lighter', 'white', '1px', 215, 302)
 | 
					        addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      const topAuthor = this.yearStats.topAuthors[0]
 | 
					      const topAuthor = this.yearStats.topAuthors[0]
 | 
				
			||||||
      if (topAuthor) {
 | 
					      if (topAuthor) {
 | 
				
			||||||
        addText('TOP AUTHOR', '12px', 'normal', tanColor, '1px', 20, 335)
 | 
					        addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
 | 
				
			||||||
        addText(topAuthor.name, '18px', 'bolder', 'white', '0px', 20, 357, 180)
 | 
					        addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
 | 
				
			||||||
        addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '14px', 'lighter', 'white', '1px', 20, 377)
 | 
					        addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.dataUrl = canvas.toDataURL('png')
 | 
					      this.dataUrl = canvas.toDataURL('png')
 | 
				
			||||||
@ -173,7 +188,7 @@ export default {
 | 
				
			|||||||
      let year = new Date().getFullYear()
 | 
					      let year = new Date().getFullYear()
 | 
				
			||||||
      if (new Date().getMonth() < 11) year--
 | 
					      if (new Date().getMonth() < 11) year--
 | 
				
			||||||
      this.year = year
 | 
					      this.year = year
 | 
				
			||||||
      this.yearStats = await this.$axios.$get(`/api/me/year/${year}/stats`).catch((err) => {
 | 
					      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
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										205
									
								
								client/components/stats/YearInReviewServer.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										205
									
								
								client/components/stats/YearInReviewServer.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,205 @@
 | 
				
			|||||||
 | 
					<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 = 800
 | 
				
			||||||
 | 
					      canvas.height = 800
 | 
				
			||||||
 | 
					      const ctx = canvas.getContext('2d')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const createRoundedRect = (x, y, w, h) => {
 | 
				
			||||||
 | 
					        const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
 | 
				
			||||||
 | 
					        grd1.addColorStop(0, '#44444466')
 | 
				
			||||||
 | 
					        grd1.addColorStop(1, '#ffffff22')
 | 
				
			||||||
 | 
					        ctx.fillStyle = grd1
 | 
				
			||||||
 | 
					        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, 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
 | 
				
			||||||
 | 
					      let imgsToAdd = {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.yearStats.booksAddedWithCovers.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(-130, -120)
 | 
				
			||||||
 | 
					        for (let x = 0; x < 5; x++) {
 | 
				
			||||||
 | 
					          for (let y = 0; y < 5; y++) {
 | 
				
			||||||
 | 
					            const coverIndex = index % this.yearStats.booksAddedWithCovers.length
 | 
				
			||||||
 | 
					            let libraryItemId = this.yearStats.booksAddedWithCovers[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, 215 * x, 215 * y, 215, 215)
 | 
				
			||||||
 | 
					                if (!imgsToAdd[libraryItemId]) {
 | 
				
			||||||
 | 
					                  imgsToAdd[libraryItemId] = {
 | 
				
			||||||
 | 
					                    img,
 | 
				
			||||||
 | 
					                    sx,
 | 
				
			||||||
 | 
					                    sy,
 | 
				
			||||||
 | 
					                    sw
 | 
				
			||||||
 | 
					                  }
 | 
				
			||||||
 | 
					                }
 | 
				
			||||||
 | 
					                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(40, 100, 230, 100)
 | 
				
			||||||
 | 
					      ctx.textAlign = 'center'
 | 
				
			||||||
 | 
					      addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
 | 
				
			||||||
 | 
					      addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Box top right
 | 
				
			||||||
 | 
					      createRoundedRect(285, 100, 230, 100)
 | 
				
			||||||
 | 
					      addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
 | 
				
			||||||
 | 
					      addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Box bottom left
 | 
				
			||||||
 | 
					      createRoundedRect(530, 100, 230, 100)
 | 
				
			||||||
 | 
					      addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
 | 
				
			||||||
 | 
					      addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Text stats
 | 
				
			||||||
 | 
					      if (this.yearStats.totalBooksAddedSize) {
 | 
				
			||||||
 | 
					        addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
 | 
				
			||||||
 | 
					        addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
 | 
				
			||||||
 | 
					        addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.yearStats.totalBooksAddedDuration) {
 | 
				
			||||||
 | 
					        addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
 | 
				
			||||||
 | 
					        addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
 | 
				
			||||||
 | 
					        addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      // Bottom images
 | 
				
			||||||
 | 
					      imgsToAdd = Object.values(imgsToAdd)
 | 
				
			||||||
 | 
					      if (imgsToAdd.length >= 5) {
 | 
				
			||||||
 | 
					        addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (let i = 0; i < 5; i++) {
 | 
				
			||||||
 | 
					          let imgToAdd = imgsToAdd[i]
 | 
				
			||||||
 | 
					          ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      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/stats/year/${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>
 | 
				
			||||||
@ -63,11 +63,13 @@
 | 
				
			|||||||
      </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" @click.stop="clickShowYearInReview">Year in Review</ui-btn>
 | 
					      <ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}</ui-btn>
 | 
				
			||||||
      <div v-if="showYearInReview">
 | 
					      <div v-if="showYearInReview">
 | 
				
			||||||
        <div class="w-full h-px bg-slate-200/10 my-4" />
 | 
					        <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 ref="yearInReview" :processing.sync="processingYearInReview" />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        <stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" />
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    </app-settings-content>
 | 
					    </app-settings-content>
 | 
				
			||||||
  </div>
 | 
					  </div>
 | 
				
			||||||
@ -80,7 +82,8 @@ export default {
 | 
				
			|||||||
      listeningStats: null,
 | 
					      listeningStats: null,
 | 
				
			||||||
      windowWidth: 0,
 | 
					      windowWidth: 0,
 | 
				
			||||||
      showYearInReview: false,
 | 
					      showYearInReview: false,
 | 
				
			||||||
      processingYearInReview: false
 | 
					      processingYearInReview: false,
 | 
				
			||||||
 | 
					      processingYearInReviewAlt: false
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  watch: {
 | 
					  watch: {
 | 
				
			||||||
@ -126,6 +129,10 @@ export default {
 | 
				
			|||||||
    clickShowYearInReview() {
 | 
					    clickShowYearInReview() {
 | 
				
			||||||
      if (this.showYearInReview) {
 | 
					      if (this.showYearInReview) {
 | 
				
			||||||
        this.$refs.yearInReview.refresh()
 | 
					        this.$refs.yearInReview.refresh()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (this.$refs.yearInReviewAlt) {
 | 
				
			||||||
 | 
					          this.$refs.yearInReviewAlt.refresh()
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        this.showYearInReview = true
 | 
					        this.showYearInReview = true
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
				
			|||||||
@ -336,6 +336,7 @@ class MeController {
 | 
				
			|||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
 | 
					   * GET: /api/stats/year/:year
 | 
				
			||||||
   * 
 | 
					   * 
 | 
				
			||||||
   * @param {import('express').Request} req 
 | 
					   * @param {import('express').Request} req 
 | 
				
			||||||
   * @param {import('express').Response} res 
 | 
					   * @param {import('express').Response} res 
 | 
				
			||||||
@ -346,7 +347,7 @@ class MeController {
 | 
				
			|||||||
      Logger.error(`[MeController] Invalid year "${year}"`)
 | 
					      Logger.error(`[MeController] Invalid year "${year}"`)
 | 
				
			||||||
      return res.status(400).send('Invalid year')
 | 
					      return res.status(400).send('Invalid year')
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
    const data = await userStats.getStatsForYear(req.user.id, year)
 | 
					    const data = await userStats.getStatsForYear(req.user, year)
 | 
				
			||||||
    res.json(data)
 | 
					    res.json(data)
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -11,6 +11,7 @@ const { isObject, getTitleIgnorePrefix } = require('../utils/index')
 | 
				
			|||||||
const { sanitizeFilename } = require('../utils/fileUtils')
 | 
					const { sanitizeFilename } = require('../utils/fileUtils')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const TaskManager = require('../managers/TaskManager')
 | 
					const TaskManager = require('../managers/TaskManager')
 | 
				
			||||||
 | 
					const adminStats = require('../utils/queries/adminStats')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
//
 | 
					//
 | 
				
			||||||
// This is a controller for routes that don't have a home yet :(
 | 
					// This is a controller for routes that don't have a home yet :(
 | 
				
			||||||
@ -696,5 +697,25 @@ class MiscController {
 | 
				
			|||||||
      serverSettings: Database.serverSettings.toJSONForBrowser()
 | 
					      serverSettings: Database.serverSettings.toJSONForBrowser()
 | 
				
			||||||
    })
 | 
					    })
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * GET: /api/me/stats/year/:year
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
 | 
					   * @param {import('express').Request} req 
 | 
				
			||||||
 | 
					   * @param {import('express').Response} res 
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async getAdminStatsForYear(req, res) {
 | 
				
			||||||
 | 
					    if (!req.user.isAdminOrUp) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Non-admin user "${req.user.username}" attempted to get admin stats for year`)
 | 
				
			||||||
 | 
					      return res.sendStatus(403)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const year = Number(req.params.year)
 | 
				
			||||||
 | 
					    if (isNaN(year) || year < 2000 || year > 9999) {
 | 
				
			||||||
 | 
					      Logger.error(`[MiscController] Invalid year "${year}"`)
 | 
				
			||||||
 | 
					      return res.status(400).send('Invalid year')
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    const stats = await adminStats.getStatsForYear(year)
 | 
				
			||||||
 | 
					    res.json(stats)
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
module.exports = new MiscController()
 | 
					module.exports = new MiscController()
 | 
				
			||||||
 | 
				
			|||||||
@ -180,7 +180,7 @@ class ApiRouter {
 | 
				
			|||||||
    this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
 | 
					    this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this))
 | 
				
			||||||
    this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
 | 
					    this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this))
 | 
				
			||||||
    this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
 | 
					    this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this))
 | 
				
			||||||
    this.router.get('/me/year/:year/stats', MeController.getStatsForYear.bind(this))
 | 
					    this.router.get('/me/stats/year/:year', MeController.getStatsForYear.bind(this))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    //
 | 
					    //
 | 
				
			||||||
    // Backup Routes
 | 
					    // Backup Routes
 | 
				
			||||||
@ -317,6 +317,7 @@ class ApiRouter {
 | 
				
			|||||||
    this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
 | 
					    this.router.get('/auth-settings', MiscController.getAuthSettings.bind(this))
 | 
				
			||||||
    this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
 | 
					    this.router.patch('/auth-settings', MiscController.updateAuthSettings.bind(this))
 | 
				
			||||||
    this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
 | 
					    this.router.post('/watcher/update', MiscController.updateWatchedPath.bind(this))
 | 
				
			||||||
 | 
					    this.router.get('/stats/year/:year', MiscController.getAdminStatsForYear.bind(this))
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async getDirectories(dir, relpath, excludedDirs, level = 0) {
 | 
					  async getDirectories(dir, relpath, excludedDirs, level = 0) {
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										118
									
								
								server/utils/queries/adminStats.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										118
									
								
								server/utils/queries/adminStats.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,118 @@
 | 
				
			|||||||
 | 
					const Sequelize = require('sequelize')
 | 
				
			||||||
 | 
					const Database = require('../../Database')
 | 
				
			||||||
 | 
					const PlaybackSession = require('../../models/PlaybackSession')
 | 
				
			||||||
 | 
					const fsExtra = require('../../libs/fsExtra')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module.exports = {
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
 | 
					   * @param {number} year YYYY
 | 
				
			||||||
 | 
					   * @returns {Promise<PlaybackSession[]>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async getListeningSessionsForYear(year) {
 | 
				
			||||||
 | 
					    const sessions = await Database.playbackSessionModel.findAll({
 | 
				
			||||||
 | 
					      where: {
 | 
				
			||||||
 | 
					        createdAt: {
 | 
				
			||||||
 | 
					          [Sequelize.Op.gte]: `${year}-01-01`,
 | 
				
			||||||
 | 
					          [Sequelize.Op.lt]: `${year + 1}-01-01`
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    return sessions
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
 | 
					   * @param {number} year YYYY
 | 
				
			||||||
 | 
					   * @returns {Promise<number>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async getNumAuthorsAddedForYear(year) {
 | 
				
			||||||
 | 
					    const count = await Database.authorModel.count({
 | 
				
			||||||
 | 
					      where: {
 | 
				
			||||||
 | 
					        createdAt: {
 | 
				
			||||||
 | 
					          [Sequelize.Op.gte]: `${year}-01-01`,
 | 
				
			||||||
 | 
					          [Sequelize.Op.lt]: `${year + 1}-01-01`
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    return count
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
 | 
					   * @param {number} year YYYY
 | 
				
			||||||
 | 
					   * @returns {Promise<import('../../models/Book')[]>}
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async getBooksAddedForYear(year) {
 | 
				
			||||||
 | 
					    const books = await Database.bookModel.findAll({
 | 
				
			||||||
 | 
					      attributes: ['id', 'title', 'coverPath', 'duration', 'createdAt'],
 | 
				
			||||||
 | 
					      where: {
 | 
				
			||||||
 | 
					        createdAt: {
 | 
				
			||||||
 | 
					          [Sequelize.Op.gte]: `${year}-01-01`,
 | 
				
			||||||
 | 
					          [Sequelize.Op.lt]: `${year + 1}-01-01`
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      include: {
 | 
				
			||||||
 | 
					        model: Database.libraryItemModel,
 | 
				
			||||||
 | 
					        attributes: ['id', 'mediaId', 'mediaType', 'size'],
 | 
				
			||||||
 | 
					        required: true
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      order: Database.sequelize.random()
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    return books
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  /**
 | 
				
			||||||
 | 
					   * 
 | 
				
			||||||
 | 
					   * @param {number} year YYYY
 | 
				
			||||||
 | 
					   */
 | 
				
			||||||
 | 
					  async getStatsForYear(year) {
 | 
				
			||||||
 | 
					    const booksAdded = await this.getBooksAddedForYear(year)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let totalBooksAddedSize = 0
 | 
				
			||||||
 | 
					    let totalBooksAddedDuration = 0
 | 
				
			||||||
 | 
					    const booksWithCovers = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const book of booksAdded) {
 | 
				
			||||||
 | 
					      // Grab first 25 that have a cover
 | 
				
			||||||
 | 
					      if (book.coverPath && !booksWithCovers.includes(book.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(book.coverPath)) {
 | 
				
			||||||
 | 
					        booksWithCovers.push(book.libraryItem.id)
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (book.duration && !isNaN(book.duration)) {
 | 
				
			||||||
 | 
					        totalBooksAddedDuration += book.duration
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      if (book.libraryItem.size && !isNaN(book.libraryItem.size)) {
 | 
				
			||||||
 | 
					        totalBooksAddedSize += book.libraryItem.size
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const listeningSessions = await this.getListeningSessionsForYear(year)
 | 
				
			||||||
 | 
					    let totalListeningTime = 0
 | 
				
			||||||
 | 
					    for (const listeningSession of listeningSessions) {
 | 
				
			||||||
 | 
					      totalListeningTime += (listeningSession.timeListening || 0)
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // 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";`, {
 | 
				
			||||||
 | 
					      replacements: {
 | 
				
			||||||
 | 
					        nextYear: year + 1
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    })
 | 
				
			||||||
 | 
					    const totalStatResults = totalStatResultsRow[0]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					      numListeningSessions: listeningSessions.length,
 | 
				
			||||||
 | 
					      numBooksAdded: booksAdded.length,
 | 
				
			||||||
 | 
					      numAuthorsAdded,
 | 
				
			||||||
 | 
					      totalBooksAddedSize,
 | 
				
			||||||
 | 
					      totalBooksAddedDuration: Math.round(totalBooksAddedDuration),
 | 
				
			||||||
 | 
					      booksAddedWithCovers: booksWithCovers,
 | 
				
			||||||
 | 
					      totalBooksSize: totalStatResults?.totalSize || 0,
 | 
				
			||||||
 | 
					      totalBooksDuration: totalStatResults?.totalDuration || 0,
 | 
				
			||||||
 | 
					      totalListeningTime,
 | 
				
			||||||
 | 
					      numBooks: totalStatResults?.totalItems || 0
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -18,9 +18,6 @@ module.exports = {
 | 
				
			|||||||
        createdAt: {
 | 
					        createdAt: {
 | 
				
			||||||
          [Sequelize.Op.gte]: `${year}-01-01`,
 | 
					          [Sequelize.Op.gte]: `${year}-01-01`,
 | 
				
			||||||
          [Sequelize.Op.lt]: `${year + 1}-01-01`
 | 
					          [Sequelize.Op.lt]: `${year + 1}-01-01`
 | 
				
			||||||
        },
 | 
					 | 
				
			||||||
        timeListening: {
 | 
					 | 
				
			||||||
          [Sequelize.Op.gt]: 5
 | 
					 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      include: {
 | 
					      include: {
 | 
				
			||||||
@ -66,10 +63,11 @@ module.exports = {
 | 
				
			|||||||
  },
 | 
					  },
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  /**
 | 
					  /**
 | 
				
			||||||
   * @param {string} userId
 | 
					   * @param {import('../../objects/user/User')} user
 | 
				
			||||||
   * @param {number} year YYYY
 | 
					   * @param {number} year YYYY
 | 
				
			||||||
   */
 | 
					   */
 | 
				
			||||||
  async getStatsForYear(userId, year) {
 | 
					  async getStatsForYear(user, year) {
 | 
				
			||||||
 | 
					    const userId = user.id
 | 
				
			||||||
    const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
 | 
					    const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let totalBookListeningTime = 0
 | 
					    let totalBookListeningTime = 0
 | 
				
			||||||
@ -84,8 +82,8 @@ module.exports = {
 | 
				
			|||||||
    const booksWithCovers = []
 | 
					    const booksWithCovers = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    for (const ls of listeningSessions) {
 | 
					    for (const ls of listeningSessions) {
 | 
				
			||||||
      // Grab first 16 that have a cover
 | 
					      // Grab first 25 that have a cover
 | 
				
			||||||
      if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 16 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
 | 
					      if (ls.mediaItem?.coverPath && !booksWithCovers.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)
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user