mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Add playing podcast episodes, episode progress, podcast page, podcast home page shelves
This commit is contained in:
		
							parent
							
								
									e32d05ea27
								
							
						
					
					
						commit
						0e665e2091
					
				@ -151,7 +151,9 @@ export default {
 | 
			
		||||
      this.$store.commit('showEditModalOnTab', { libraryItem: this.book, tab: 'download' })
 | 
			
		||||
    },
 | 
			
		||||
    startStream() {
 | 
			
		||||
      this.$eventBus.$emit('play-item', this.book.id)
 | 
			
		||||
      this.$eventBus.$emit('play-item', {
 | 
			
		||||
        libraryItemId: this.book.id
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    editClick() {
 | 
			
		||||
      this.$emit('edit', this.book)
 | 
			
		||||
 | 
			
		||||
@ -167,7 +167,7 @@ export default {
 | 
			
		||||
    libraryItemUpdated(libraryItem) {
 | 
			
		||||
      console.log('libraryItem updated', libraryItem)
 | 
			
		||||
      this.shelves.forEach((shelf) => {
 | 
			
		||||
        if (shelf.type === 'books') {
 | 
			
		||||
        if (shelf.type == 'book' || shelf.type == 'podcast') {
 | 
			
		||||
          shelf.entities = shelf.entities.map((ent) => {
 | 
			
		||||
            if (ent.id === libraryItem.id) {
 | 
			
		||||
              return libraryItem
 | 
			
		||||
@ -186,7 +186,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    removeBookFromShelf(libraryItem) {
 | 
			
		||||
      this.shelves.forEach((shelf) => {
 | 
			
		||||
        if (shelf.type === 'books') {
 | 
			
		||||
        if (shelf.type == 'book' || shelf.type == 'podcast') {
 | 
			
		||||
          shelf.entities = shelf.entities.filter((ent) => {
 | 
			
		||||
            return ent.id !== libraryItem.id
 | 
			
		||||
          })
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@
 | 
			
		||||
  <div class="relative">
 | 
			
		||||
    <div ref="shelf" class="w-full max-w-full categorizedBookshelfRow relative overflow-x-scroll overflow-y-hidden z-10" :style="{ paddingLeft: paddingLeft * sizeMultiplier + 'rem', height: shelfHeight + 'px' }" @scroll="scrolled">
 | 
			
		||||
      <div class="w-full h-full pt-6">
 | 
			
		||||
        <div v-if="shelf.type === 'book'" class="flex items-center">
 | 
			
		||||
        <div v-if="shelf.type === 'book' || shelf.type === 'podcast'" class="flex items-center">
 | 
			
		||||
          <template v-for="(entity, index) in shelf.entities">
 | 
			
		||||
            <cards-lazy-book-card :key="entity.id" :ref="`shelf-book-${entity.id}`" :index="index" :width="bookCoverWidth" :height="bookCoverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" :book-mount="entity" class="relative mx-2" @hook:updated="updatedBookCard" @select="selectItem" @edit="editBook" />
 | 
			
		||||
          </template>
 | 
			
		||||
 | 
			
		||||
@ -133,6 +133,10 @@ export default {
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    setPlaying(isPlaying) {
 | 
			
		||||
      this.isPlaying = isPlaying
 | 
			
		||||
      this.$store.commit('setIsPlaying', isPlaying)
 | 
			
		||||
    },
 | 
			
		||||
    setSleepTimer(seconds) {
 | 
			
		||||
      this.sleepTimerSet = true
 | 
			
		||||
      this.sleepTimerTime = seconds
 | 
			
		||||
@ -221,7 +225,7 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    closePlayer() {
 | 
			
		||||
      this.playerHandler.closePlayer()
 | 
			
		||||
      this.$store.commit('setLibraryItemStream', null)
 | 
			
		||||
      this.$store.commit('setMediaPlaying', null)
 | 
			
		||||
    },
 | 
			
		||||
    streamProgress(data) {
 | 
			
		||||
      if (!data.numSegments) return
 | 
			
		||||
@ -234,7 +238,10 @@ export default {
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    sessionOpen(session) {
 | 
			
		||||
      this.$store.commit('setLibraryItemStream', session.libraryItem)
 | 
			
		||||
      this.$store.commit('setMediaPlaying', {
 | 
			
		||||
        libraryItem: session.libraryItem,
 | 
			
		||||
        episodeId: session.episodeId
 | 
			
		||||
      })
 | 
			
		||||
      this.playerHandler.prepareOpenSession(session)
 | 
			
		||||
    },
 | 
			
		||||
    streamClosed(streamId) {
 | 
			
		||||
@ -271,24 +278,40 @@ export default {
 | 
			
		||||
        this.playerHandler.switchPlayer()
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    async playLibraryItem(libraryItemId) {
 | 
			
		||||
    async playLibraryItem(payload) {
 | 
			
		||||
      var libraryItemId = payload.libraryItemId
 | 
			
		||||
      var episodeId = payload.episodeId || null
 | 
			
		||||
 | 
			
		||||
      if (this.playerHandler.libraryItemId == libraryItemId && this.playerHandler.episodeId == episodeId) {
 | 
			
		||||
        this.playerHandler.play()
 | 
			
		||||
        return
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      var libraryItem = await this.$axios.$get(`/api/items/${libraryItemId}?expanded=1`).catch((error) => {
 | 
			
		||||
        console.error('Failed to fetch full item', error)
 | 
			
		||||
        return null
 | 
			
		||||
      })
 | 
			
		||||
      if (!libraryItem) return
 | 
			
		||||
      this.$store.commit('setLibraryItemStream', libraryItem)
 | 
			
		||||
      this.$store.commit('setMediaPlaying', {
 | 
			
		||||
        libraryItem,
 | 
			
		||||
        episodeId
 | 
			
		||||
      })
 | 
			
		||||
 | 
			
		||||
      this.playerHandler.load(libraryItem, true)
 | 
			
		||||
      this.playerHandler.load(libraryItem, episodeId, true)
 | 
			
		||||
    },
 | 
			
		||||
    pauseItem() {
 | 
			
		||||
      this.playerHandler.pause()
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.$eventBus.$on('cast-session-active', this.castSessionActive)
 | 
			
		||||
    this.$eventBus.$on('play-item', this.playLibraryItem)
 | 
			
		||||
    this.$eventBus.$on('pause-item', this.pauseItem)
 | 
			
		||||
  },
 | 
			
		||||
  beforeDestroy() {
 | 
			
		||||
    this.$eventBus.$off('cast-session-active', this.castSessionActive)
 | 
			
		||||
    this.$eventBus.$off('play-item', this.playLibraryItem)
 | 
			
		||||
    this.$eventBus.$off('pause-item', this.pauseItem)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
@ -527,7 +527,9 @@ export default {
 | 
			
		||||
    },
 | 
			
		||||
    play() {
 | 
			
		||||
      var eventBus = this.$eventBus || this.$nuxt.$eventBus
 | 
			
		||||
      eventBus.$emit('play-item', this.libraryItemId)
 | 
			
		||||
      eventBus.$emit('play-item', {
 | 
			
		||||
        libraryItemId: this.libraryItemId
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    mouseover() {
 | 
			
		||||
      this.isHovering = true
 | 
			
		||||
 | 
			
		||||
@ -123,8 +123,9 @@ export default {
 | 
			
		||||
      if (!this.userCanUpdate && !this.userCanDownload) return []
 | 
			
		||||
      return this.tabs.filter((tab) => {
 | 
			
		||||
        if (tab.id === 'download' && this.isMissing) return false
 | 
			
		||||
        if (tab.id === 'chapters' && this.mediaType !== 'book') return false
 | 
			
		||||
        if (tab.id === 'episodes' && this.mediaType !== 'podcast') return false
 | 
			
		||||
        if (this.mediaType == 'podcast' && (tab.id == 'match' || tab.id == 'chapters')) return false
 | 
			
		||||
        if (this.mediaType == 'book' && tab.id == 'episodes') return false
 | 
			
		||||
 | 
			
		||||
        if ((tab.id === 'download' || tab.id === 'files') && this.userCanDownload) return true
 | 
			
		||||
        if (tab.id !== 'download' && tab.id !== 'files' && this.userCanUpdate) return true
 | 
			
		||||
        if (tab.id === 'match' && this.userCanUpdate && this.showExperimentalFeatures) return true
 | 
			
		||||
 | 
			
		||||
@ -35,7 +35,7 @@
 | 
			
		||||
                </div>
 | 
			
		||||
              </td>
 | 
			
		||||
              <td v-if="userCanDownload && !isMissing" class="text-center">
 | 
			
		||||
                <a :href="`/s/item/${libraryItemId}${$encodeUriPath(file.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
 | 
			
		||||
                <a :href="`/s/item/${libraryItemId}/${$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </template>
 | 
			
		||||
 | 
			
		||||
@ -38,7 +38,7 @@
 | 
			
		||||
                {{ $secondsToTimestamp(track.duration) }}
 | 
			
		||||
              </td>
 | 
			
		||||
              <td v-if="userCanDownload" class="text-center">
 | 
			
		||||
                <a :href="`/s/item/${libraryItemId}${$encodeUriPath(track.metadata.relPath)}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
 | 
			
		||||
                <a :href="`/s/item/${libraryItemId}/${$encodeUriPath(track.metadata.relPath).replace(/^\//, '')}?token=${userToken}`" download><span class="material-icons icon-text">download</span></a>
 | 
			
		||||
              </td>
 | 
			
		||||
            </tr>
 | 
			
		||||
          </template>
 | 
			
		||||
 | 
			
		||||
@ -27,11 +27,6 @@
 | 
			
		||||
        <span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
 | 
			
		||||
      </div> -->
 | 
			
		||||
    </div>
 | 
			
		||||
    <!-- <div class="absolute top-0 left-0 z-40 bg-red-500 w-full h-full">
 | 
			
		||||
      <div class="w-24 h-full absolute top-0 -right-24 transform transition-transform" :class="isHovering ? 'translate-x-0' : '-translate-x-24'">
 | 
			
		||||
        <span class="material-icons">edit</span>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div> -->
 | 
			
		||||
    <div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
 | 
			
		||||
      <div class="flex h-full items-center">
 | 
			
		||||
        <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
 | 
			
		||||
@ -126,7 +121,9 @@ export default {
 | 
			
		||||
      this.isHovering = false
 | 
			
		||||
    },
 | 
			
		||||
    playClick() {
 | 
			
		||||
      this.$eventBus.$emit('play-item', this.book.id)
 | 
			
		||||
      this.$eventBus.$emit('play-item', {
 | 
			
		||||
        libraryItemId: this.book.id
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    clickEdit() {
 | 
			
		||||
      this.$emit('edit', this.book)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										161
									
								
								client/components/tables/podcast/EpisodeTableRow.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								client/components/tables/podcast/EpisodeTableRow.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,161 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
 | 
			
		||||
    <div v-if="episode" class="flex items-center h-24">
 | 
			
		||||
      <div class="w-12 min-w-12 max-w-16 h-full">
 | 
			
		||||
        <div class="flex h-full items-center justify-center">
 | 
			
		||||
          <span class="material-icons drag-handle text-lg text-white text-opacity-50 hover:text-opacity-100">menu</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="flex-grow px-2">
 | 
			
		||||
        <p class="text-sm font-semibold">
 | 
			
		||||
          {{ title }}
 | 
			
		||||
        </p>
 | 
			
		||||
        <p class="text-sm">
 | 
			
		||||
          {{ description }}
 | 
			
		||||
        </p>
 | 
			
		||||
        <div class="flex items-center pt-2">
 | 
			
		||||
          <div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
 | 
			
		||||
            <span class="material-icons">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
 | 
			
		||||
            <p class="pl-2 pr-1 text-sm">{{ timeRemaining }}</p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
 | 
			
		||||
            <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
 | 
			
		||||
          </ui-tooltip>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="w-24 min-w-24" />
 | 
			
		||||
    </div>
 | 
			
		||||
    <div class="w-24 min-w-24 -right-0 absolute top-0 h-full transform transition-transform" :class="!isHovering ? 'translate-x-32' : 'translate-x-0'">
 | 
			
		||||
      <div class="flex h-full items-center">
 | 
			
		||||
        <div class="mx-1">
 | 
			
		||||
          <ui-icon-btn icon="edit" borderless @click="clickEdit" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="mx-1">
 | 
			
		||||
          <ui-icon-btn icon="close" borderless @click="removeClick" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
 | 
			
		||||
    <div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-1 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
export default {
 | 
			
		||||
  props: {
 | 
			
		||||
    libraryItemId: String,
 | 
			
		||||
    episode: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    },
 | 
			
		||||
    isDragging: Boolean
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      isProcessingReadUpdate: false,
 | 
			
		||||
      processingRemove: false,
 | 
			
		||||
      isHovering: false
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    isDragging: {
 | 
			
		||||
      handler(newVal) {
 | 
			
		||||
        if (newVal) {
 | 
			
		||||
          this.isHovering = false
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    audioFile() {
 | 
			
		||||
      return this.episode.audioFile
 | 
			
		||||
    },
 | 
			
		||||
    title() {
 | 
			
		||||
      return this.episode.title || ''
 | 
			
		||||
    },
 | 
			
		||||
    description() {
 | 
			
		||||
      return this.episode.description || ''
 | 
			
		||||
    },
 | 
			
		||||
    duration() {
 | 
			
		||||
      return this.$secondsToTimestamp(this.episode.duration)
 | 
			
		||||
    },
 | 
			
		||||
    isStreaming() {
 | 
			
		||||
      return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
 | 
			
		||||
    },
 | 
			
		||||
    streamIsPlaying() {
 | 
			
		||||
      return this.$store.state.streamIsPlaying && this.isStreaming
 | 
			
		||||
    },
 | 
			
		||||
    itemProgress() {
 | 
			
		||||
      return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
 | 
			
		||||
    },
 | 
			
		||||
    itemProgressPercent() {
 | 
			
		||||
      return this.itemProgress ? this.itemProgress.progress : 0
 | 
			
		||||
    },
 | 
			
		||||
    userIsFinished() {
 | 
			
		||||
      return this.itemProgress ? !!this.itemProgress.isFinished : false
 | 
			
		||||
    },
 | 
			
		||||
    timeRemaining() {
 | 
			
		||||
      if (this.streamIsPlaying) return 'Playing'
 | 
			
		||||
      if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
 | 
			
		||||
      if (this.userIsFinished) return 'Finished'
 | 
			
		||||
      var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
 | 
			
		||||
      return `${this.$elapsedPretty(remaining)} left`
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    mouseover() {
 | 
			
		||||
      if (this.isDragging) return
 | 
			
		||||
      this.isHovering = true
 | 
			
		||||
    },
 | 
			
		||||
    mouseleave() {
 | 
			
		||||
      this.isHovering = false
 | 
			
		||||
    },
 | 
			
		||||
    clickEdit() {},
 | 
			
		||||
    playClick() {
 | 
			
		||||
      if (this.streamIsPlaying) {
 | 
			
		||||
        this.$eventBus.$emit('pause-item')
 | 
			
		||||
      } else {
 | 
			
		||||
        this.$eventBus.$emit('play-item', {
 | 
			
		||||
          libraryItemId: this.libraryItemId,
 | 
			
		||||
          episodeId: this.episode.id
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    },
 | 
			
		||||
    toggleFinished() {
 | 
			
		||||
      var updatePayload = {
 | 
			
		||||
        isFinished: !this.userIsFinished
 | 
			
		||||
      }
 | 
			
		||||
      this.isProcessingReadUpdate = true
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
 | 
			
		||||
        .then(() => {
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error('Failed', error)
 | 
			
		||||
          this.isProcessingReadUpdate = false
 | 
			
		||||
          this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    removeClick() {
 | 
			
		||||
      this.processingRemove = true
 | 
			
		||||
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$delete(`/api/items/${this.libraryItemId}/episode/${this.episode.id}`)
 | 
			
		||||
        .then((updatedPodcast) => {
 | 
			
		||||
          console.log(`Episode removed from podcast`, updatedPodcast)
 | 
			
		||||
          this.$toast.success('Episode removed from podcast')
 | 
			
		||||
          this.processingRemove = false
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error('Failed to remove episode from podcast', error)
 | 
			
		||||
          this.$toast.error('Failed to remove episode from podcast')
 | 
			
		||||
          this.processingRemove = false
 | 
			
		||||
        })
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {}
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										99
									
								
								client/components/tables/podcast/EpisodesTable.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								client/components/tables/podcast/EpisodesTable.vue
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,99 @@
 | 
			
		||||
<template>
 | 
			
		||||
  <div class="w-full py-6">
 | 
			
		||||
    <p class="text-lg mb-0 font-semibold">Episodes</p>
 | 
			
		||||
    <draggable v-model="episodesCopy" v-bind="dragOptions" class="list-group" handle=".drag-handle" draggable=".item" tag="div" @start="drag = true" @end="drag = false" @update="draggableUpdate">
 | 
			
		||||
      <transition-group type="transition" :name="!drag ? 'episode' : null">
 | 
			
		||||
        <template v-for="episode in episodesCopy">
 | 
			
		||||
          <tables-podcast-episode-table-row :key="episode.id" :is-dragging="drag" :episode="episode" :library-item-id="libraryItem.id" class="item" :class="drag ? '' : 'episode'" />
 | 
			
		||||
        </template>
 | 
			
		||||
      </transition-group>
 | 
			
		||||
    </draggable>
 | 
			
		||||
  </div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script>
 | 
			
		||||
import draggable from 'vuedraggable'
 | 
			
		||||
 | 
			
		||||
export default {
 | 
			
		||||
  components: {
 | 
			
		||||
    draggable
 | 
			
		||||
  },
 | 
			
		||||
  props: {
 | 
			
		||||
    libraryItem: {
 | 
			
		||||
      type: Object,
 | 
			
		||||
      default: () => {}
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  data() {
 | 
			
		||||
    return {
 | 
			
		||||
      drag: false,
 | 
			
		||||
      dragOptions: {
 | 
			
		||||
        animation: 200,
 | 
			
		||||
        group: 'description',
 | 
			
		||||
        ghostClass: 'ghost'
 | 
			
		||||
      },
 | 
			
		||||
      episodesCopy: []
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  watch: {
 | 
			
		||||
    libraryItem: {
 | 
			
		||||
      handler(newVal) {
 | 
			
		||||
        this.init()
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  computed: {
 | 
			
		||||
    media() {
 | 
			
		||||
      return this.libraryItem.media || {}
 | 
			
		||||
    },
 | 
			
		||||
    mediaMetadata() {
 | 
			
		||||
      return this.media.metadata || {}
 | 
			
		||||
    },
 | 
			
		||||
    episodes() {
 | 
			
		||||
      return this.media.episodes || []
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  methods: {
 | 
			
		||||
    draggableUpdate() {
 | 
			
		||||
      var episodesUpdate = {
 | 
			
		||||
        episodes: this.episodesCopy.map((b) => b.id)
 | 
			
		||||
      }
 | 
			
		||||
      this.$axios
 | 
			
		||||
        .$patch(`/api/items/${this.libraryItem.id}/episodes`, episodesUpdate)
 | 
			
		||||
        .then((podcast) => {
 | 
			
		||||
          console.log('Podcast updated', podcast)
 | 
			
		||||
        })
 | 
			
		||||
        .catch((error) => {
 | 
			
		||||
          console.error('Failed to update podcast', error)
 | 
			
		||||
          this.$toast.error('Failed to save podcast episode order')
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    init() {
 | 
			
		||||
      this.episodesCopy = this.episodes.map((ep) => {
 | 
			
		||||
        return {
 | 
			
		||||
          ...ep
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  mounted() {
 | 
			
		||||
    this.init()
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style>
 | 
			
		||||
.episode-item {
 | 
			
		||||
  transition: all 0.4s ease;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.episode-enter-from,
 | 
			
		||||
.episode-leave-to {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transform: translateX(30px);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.episode-leave-active {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
@ -120,7 +120,9 @@ export default {
 | 
			
		||||
        return !prog || !prog.isFinished
 | 
			
		||||
      })
 | 
			
		||||
      if (nextBookNotRead) {
 | 
			
		||||
        this.$eventBus.$emit('play-item', nextBookNotRead.id)
 | 
			
		||||
        this.$eventBus.$emit('play-item', {
 | 
			
		||||
          libraryItemId: nextBookNotRead.id
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@
 | 
			
		||||
            <covers-book-cover :library-item="libraryItem" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
 | 
			
		||||
 | 
			
		||||
            <!-- Item Progress Bar -->
 | 
			
		||||
            <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
 | 
			
		||||
            <div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div>
 | 
			
		||||
 | 
			
		||||
            <!-- Item Cover Overlay -->
 | 
			
		||||
            <div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
 | 
			
		||||
@ -96,7 +96,7 @@
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          <!-- Progress -->
 | 
			
		||||
          <div v-if="progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
 | 
			
		||||
          <div v-if="!isPodcast && progressPercent > 0" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0" :class="resettingProgress ? 'opacity-25' : ''">
 | 
			
		||||
            <p v-if="progressPercent < 1" class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
 | 
			
		||||
            <p v-else class="text-xs">Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}</p>
 | 
			
		||||
            <p v-if="progressPercent < 1" class="text-gray-200 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
 | 
			
		||||
@ -145,6 +145,8 @@
 | 
			
		||||
 | 
			
		||||
          <widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :media="media" />
 | 
			
		||||
 | 
			
		||||
          <tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
 | 
			
		||||
 | 
			
		||||
          <tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item-id="libraryItemId" :files="libraryFiles" class="mt-6" />
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
@ -353,7 +355,20 @@ export default {
 | 
			
		||||
        })
 | 
			
		||||
    },
 | 
			
		||||
    startStream() {
 | 
			
		||||
      this.$eventBus.$emit('play-item', this.libraryItem.id)
 | 
			
		||||
      var episodeId = null
 | 
			
		||||
      if (this.isPodcast) {
 | 
			
		||||
        var episode = this.podcastEpisodes.find((ep) => {
 | 
			
		||||
          var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
 | 
			
		||||
          return !podcastProgress || !podcastProgress.isFinished
 | 
			
		||||
        })
 | 
			
		||||
        if (!episode) episode = this.podcastEpisodes[0]
 | 
			
		||||
        episodeId = episode.id
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.$eventBus.$emit('play-item', {
 | 
			
		||||
        libraryItemId: this.libraryItem.id,
 | 
			
		||||
        episodeId
 | 
			
		||||
      })
 | 
			
		||||
    },
 | 
			
		||||
    editClick() {
 | 
			
		||||
      this.$store.commit('setBookshelfBookIds', [])
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,7 @@ export default class PlayerHandler {
 | 
			
		||||
  constructor(ctx) {
 | 
			
		||||
    this.ctx = ctx
 | 
			
		||||
    this.libraryItem = null
 | 
			
		||||
    this.episodeId = null
 | 
			
		||||
    this.playWhenReady = false
 | 
			
		||||
    this.player = null
 | 
			
		||||
    this.playerState = 'IDLE'
 | 
			
		||||
@ -23,6 +24,9 @@ export default class PlayerHandler {
 | 
			
		||||
  get isCasting() {
 | 
			
		||||
    return this.ctx.$store.state.globals.isCasting
 | 
			
		||||
  }
 | 
			
		||||
  get libraryItemId() {
 | 
			
		||||
    return this.libraryItem ? this.libraryItem.id : null
 | 
			
		||||
  }
 | 
			
		||||
  get isPlayingCastedItem() {
 | 
			
		||||
    return this.libraryItem && (this.player instanceof CastPlayer)
 | 
			
		||||
  }
 | 
			
		||||
@ -36,10 +40,11 @@ export default class PlayerHandler {
 | 
			
		||||
    return this.playerState === 'PLAYING'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  load(libraryItem, playWhenReady) {
 | 
			
		||||
  load(libraryItem, episodeId, playWhenReady) {
 | 
			
		||||
    if (!this.player) this.switchPlayer()
 | 
			
		||||
 | 
			
		||||
    this.libraryItem = libraryItem
 | 
			
		||||
    this.episodeId = episodeId
 | 
			
		||||
    this.playWhenReady = playWhenReady
 | 
			
		||||
    this.prepare()
 | 
			
		||||
  }
 | 
			
		||||
@ -113,7 +118,7 @@ export default class PlayerHandler {
 | 
			
		||||
      this.ctx.setCurrentTime(this.player.getCurrentTime())
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.ctx.isPlaying = this.playerState === 'PLAYING'
 | 
			
		||||
    this.ctx.setPlaying(this.playerState === 'PLAYING')
 | 
			
		||||
    this.ctx.playerLoading = this.playerState === 'LOADING'
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -132,7 +137,9 @@ export default class PlayerHandler {
 | 
			
		||||
      forceTranscode,
 | 
			
		||||
      forceDirectPlay: this.isCasting // TODO: add transcode support for chromecast
 | 
			
		||||
    }
 | 
			
		||||
    var session = await this.ctx.$axios.$post(`/api/items/${this.libraryItem.id}/play`, payload).catch((error) => {
 | 
			
		||||
 | 
			
		||||
    var path = this.episodeId ? `/api/items/${this.libraryItem.id}/play/${this.episodeId}` : `/api/items/${this.libraryItem.id}/play`
 | 
			
		||||
    var session = await this.ctx.$axios.$post(path, payload).catch((error) => {
 | 
			
		||||
      console.error('Failed to start stream', error)
 | 
			
		||||
    })
 | 
			
		||||
    this.prepareSession(session)
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,8 @@ export const state = () => ({
 | 
			
		||||
  versionData: null,
 | 
			
		||||
  serverSettings: null,
 | 
			
		||||
  streamLibraryItem: null,
 | 
			
		||||
  streamEpisodeId: null,
 | 
			
		||||
  streamIsPlaying: false,
 | 
			
		||||
  editModalTab: 'details',
 | 
			
		||||
  showEditModal: false,
 | 
			
		||||
  showEReader: false,
 | 
			
		||||
@ -38,6 +40,10 @@ export const getters = {
 | 
			
		||||
  getNumLibraryItemsSelected: state => state.selectedLibraryItems.length,
 | 
			
		||||
  getLibraryItemIdStreaming: state => {
 | 
			
		||||
    return state.streamLibraryItem ? state.streamLibraryItem.id : null
 | 
			
		||||
  },
 | 
			
		||||
  getIsEpisodeStreaming: state => (libraryItemId, episodeId) => {
 | 
			
		||||
    if (!state.streamLibraryItem) return null
 | 
			
		||||
    return state.streamLibraryItem.id == libraryItemId && state.streamEpisodeId == episodeId
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -105,8 +111,18 @@ export const mutations = {
 | 
			
		||||
    if (!settings) return
 | 
			
		||||
    state.serverSettings = settings
 | 
			
		||||
  },
 | 
			
		||||
  setLibraryItemStream(state, libraryItem) {
 | 
			
		||||
    state.streamLibraryItem = libraryItem
 | 
			
		||||
  setMediaPlaying(state, payload) {
 | 
			
		||||
    if (!payload) {
 | 
			
		||||
      state.streamLibraryItem = null
 | 
			
		||||
      state.streamEpisodeId = null
 | 
			
		||||
      state.streamIsPlaying = false
 | 
			
		||||
    } else {
 | 
			
		||||
      state.streamLibraryItem = payload.libraryItem
 | 
			
		||||
      state.streamEpisodeId = payload.episodeId || null
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
  setIsPlaying(state, isPlaying) {
 | 
			
		||||
    state.streamIsPlaying = isPlaying
 | 
			
		||||
  },
 | 
			
		||||
  showEditModal(state, libraryItem) {
 | 
			
		||||
    state.editModalTab = 'details'
 | 
			
		||||
 | 
			
		||||
@ -22,9 +22,12 @@ export const getters = {
 | 
			
		||||
  getToken: (state) => {
 | 
			
		||||
    return state.user ? state.user.token : null
 | 
			
		||||
  },
 | 
			
		||||
  getUserMediaProgress: (state) => (libraryItemId) => {
 | 
			
		||||
  getUserMediaProgress: (state) => (libraryItemId, episodeId = null) => {
 | 
			
		||||
    if (!state.user.mediaProgress) return null
 | 
			
		||||
    return state.user.mediaProgress.find(li => li.id == libraryItemId)
 | 
			
		||||
    return state.user.mediaProgress.find(li => {
 | 
			
		||||
      if (episodeId && li.episodeId !== episodeId) return false
 | 
			
		||||
      return li.id == libraryItemId
 | 
			
		||||
    })
 | 
			
		||||
  },
 | 
			
		||||
  getUserBookmarksForItem: (state) => (libraryItemId) => {
 | 
			
		||||
    if (!state.user.bookmarks) return []
 | 
			
		||||
 | 
			
		||||
@ -384,6 +384,10 @@ class Server {
 | 
			
		||||
        Logger.error(`[Server] Library Item for session "${session.id}" does not exist "${session.libraryItemId}"`)
 | 
			
		||||
        this.playbackSessionManager.removeSession(session.id)
 | 
			
		||||
        session = null
 | 
			
		||||
      } else if (session.mediaType === 'podcast' && !sessionLibraryItem.media.checkHasEpisode(session.episodeId)) {
 | 
			
		||||
        Logger.error(`[Server] Library Item for session "${session.id}" episode ${session.episodeId} does not exist "${session.libraryItemId}"`)
 | 
			
		||||
        this.playbackSessionManager.removeSession(session.id)
 | 
			
		||||
        session = null
 | 
			
		||||
      }
 | 
			
		||||
      if (session) {
 | 
			
		||||
        session = session.toJSONForClient(sessionLibraryItem)
 | 
			
		||||
 | 
			
		||||
@ -275,6 +275,8 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
  // api/libraries/:id/personalized
 | 
			
		||||
  async getLibraryUserPersonalized(req, res) {
 | 
			
		||||
    var mediaType = req.library.mediaType
 | 
			
		||||
    var isPodcastLibrary = mediaType == 'podcast'
 | 
			
		||||
    var libraryItems = req.libraryItems
 | 
			
		||||
    var limitPerShelf = req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 12
 | 
			
		||||
    var minified = req.query.minified === '1'
 | 
			
		||||
@ -283,8 +285,8 @@ class LibraryController {
 | 
			
		||||
 | 
			
		||||
    var categories = [
 | 
			
		||||
      {
 | 
			
		||||
        id: 'continue-reading',
 | 
			
		||||
        label: 'Continue Reading',
 | 
			
		||||
        id: 'continue-listening',
 | 
			
		||||
        label: 'Continue Listening',
 | 
			
		||||
        type: req.library.mediaType,
 | 
			
		||||
        entities: libraryHelpers.getItemsMostRecentlyListened(itemsWithUserProgress, limitPerShelf, minified)
 | 
			
		||||
      },
 | 
			
		||||
@ -295,8 +297,8 @@ class LibraryController {
 | 
			
		||||
        entities: libraryHelpers.getItemsMostRecentlyAdded(libraryItems, limitPerShelf, minified)
 | 
			
		||||
      },
 | 
			
		||||
      {
 | 
			
		||||
        id: 'read-again',
 | 
			
		||||
        label: 'Read Again',
 | 
			
		||||
        id: 'listen-again',
 | 
			
		||||
        label: 'Listen Again',
 | 
			
		||||
        type: req.library.mediaType,
 | 
			
		||||
        entities: libraryHelpers.getItemsMostRecentlyFinished(itemsWithUserProgress, limitPerShelf, minified)
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -169,7 +169,24 @@ class LibraryItemController {
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
    const options = req.body || {}
 | 
			
		||||
    this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, options, res)
 | 
			
		||||
    this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, null, options, res)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // POST: api/items/:id/play/:episodeId
 | 
			
		||||
  startEpisodePlaybackSession(req, res) {
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
    if (!libraryItem.media.numTracks) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] startPlaybackSession cannot playback ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
    var episodeId = req.params.episodeId
 | 
			
		||||
    if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] startPlaybackSession episode ${episodeId} not found for item ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const options = req.body || {}
 | 
			
		||||
    this.playbackSessionManager.startSessionRequest(req.user, libraryItem, episodeId, options, res)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/items/:id/tracks
 | 
			
		||||
@ -186,6 +203,38 @@ class LibraryItemController {
 | 
			
		||||
    res.json(libraryItem.toJSON())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/items/:id/episodes
 | 
			
		||||
  async updateEpisodes(req, res) {
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
    var orderedFileData = req.body.episodes
 | 
			
		||||
    if (!libraryItem.media.setEpisodeOrder) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] updateEpisodes invalid media type ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(500)
 | 
			
		||||
    }
 | 
			
		||||
    libraryItem.media.setEpisodeOrder(orderedFileData)
 | 
			
		||||
    await this.db.updateLibraryItem(libraryItem)
 | 
			
		||||
    this.emitter('item_updated', libraryItem.toJSONExpanded())
 | 
			
		||||
    res.json(libraryItem.toJSON())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // DELETE: api/items/:id/episode/:episodeId
 | 
			
		||||
  async removeEpisode(req, res) {
 | 
			
		||||
    var episodeId = req.params.episodeId
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
    if (!libraryItem.mediaType !== 'podcast') {
 | 
			
		||||
      Logger.error(`[LibraryItemController] removeEpisode invalid media type ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(500)
 | 
			
		||||
    }
 | 
			
		||||
    if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
    libraryItem.media.removeEpisode(episodeId)
 | 
			
		||||
    await this.db.updateLibraryItem(libraryItem)
 | 
			
		||||
    this.emitter('item_updated', libraryItem.toJSONExpanded())
 | 
			
		||||
    res.json(libraryItem.toJSON())
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // POST api/items/:id/match
 | 
			
		||||
  async match(req, res) {
 | 
			
		||||
    var libraryItem = req.libraryItem
 | 
			
		||||
 | 
			
		||||
@ -43,6 +43,26 @@ class MeController {
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/me/progress/:id/:episodeId
 | 
			
		||||
  async createUpdateEpisodeMediaProgress(req, res) {
 | 
			
		||||
    var episodeId = req.params.episodeId
 | 
			
		||||
    var libraryItem = this.db.libraryItems.find(ab => ab.id === req.params.id)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      return res.status(404).send('Item not found')
 | 
			
		||||
    }
 | 
			
		||||
    if (!libraryItem.media.episodes.find(ep => ep.id === episodeId)) {
 | 
			
		||||
      Logger.error(`[MeController] removeEpisode episode ${episodeId} not found for item ${libraryItem.id}`)
 | 
			
		||||
      return res.status(404).send('Episode not found')
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    var wasUpdated = req.user.createUpdateMediaProgress(libraryItem, req.body, episodeId)
 | 
			
		||||
    if (wasUpdated) {
 | 
			
		||||
      await this.db.updateEntity('user', req.user)
 | 
			
		||||
      this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser())
 | 
			
		||||
    }
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // PATCH: api/me/progress/batch/update
 | 
			
		||||
  async batchUpdateMediaProgress(req, res) {
 | 
			
		||||
    var itemProgressPayloads = req.body
 | 
			
		||||
 | 
			
		||||
@ -25,8 +25,8 @@ class PlaybackSessionManager {
 | 
			
		||||
    return session ? session.stream : null
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async startSessionRequest(user, libraryItem, options, res) {
 | 
			
		||||
    const session = await this.startSession(user, libraryItem, options)
 | 
			
		||||
  async startSessionRequest(user, libraryItem, episodeId, options, res) {
 | 
			
		||||
    const session = await this.startSession(user, libraryItem, episodeId, options)
 | 
			
		||||
    res.json(session.toJSONForClient(libraryItem))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -42,23 +42,23 @@ class PlaybackSessionManager {
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async startSession(user, libraryItem, options) {
 | 
			
		||||
    var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options))
 | 
			
		||||
  async startSession(user, libraryItem, episodeId, options) {
 | 
			
		||||
    var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && libraryItem.media.checkCanDirectPlay(options, episodeId))
 | 
			
		||||
 | 
			
		||||
    const userProgress = user.getMediaProgress(libraryItem.id)
 | 
			
		||||
    const userProgress = user.getMediaProgress(libraryItem.id, episodeId)
 | 
			
		||||
    var userStartTime = 0
 | 
			
		||||
    if (userProgress) userStartTime = userProgress.currentTime || 0
 | 
			
		||||
    const newPlaybackSession = new PlaybackSession()
 | 
			
		||||
    newPlaybackSession.setData(libraryItem, user)
 | 
			
		||||
    newPlaybackSession.setData(libraryItem, user, episodeId)
 | 
			
		||||
 | 
			
		||||
    var audioTracks = []
 | 
			
		||||
    if (shouldDirectPlay) {
 | 
			
		||||
      Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for item "${libraryItem.id}"`)
 | 
			
		||||
      audioTracks = libraryItem.getDirectPlayTracklist(libraryItem.id)
 | 
			
		||||
      audioTracks = libraryItem.getDirectPlayTracklist(libraryItem.id, episodeId)
 | 
			
		||||
      newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
 | 
			
		||||
    } else {
 | 
			
		||||
      Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for item "${libraryItem.id}"`)
 | 
			
		||||
      var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, userStartTime, this.clientEmitter.bind(this))
 | 
			
		||||
      var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, episodeId, userStartTime, this.clientEmitter.bind(this))
 | 
			
		||||
      await stream.generatePlaylist()
 | 
			
		||||
      audioTracks = [stream.getAudioTrack()]
 | 
			
		||||
      newPlaybackSession.stream = stream
 | 
			
		||||
@ -84,7 +84,7 @@ class PlaybackSessionManager {
 | 
			
		||||
  async syncSession(user, session, syncData) {
 | 
			
		||||
    var libraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
 | 
			
		||||
    if (!libraryItem) {
 | 
			
		||||
      Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${sessino.libraryItemId}"`)
 | 
			
		||||
      Logger.error(`[PlaybackSessionManager] syncSession Library Item not found "${session.libraryItemId}"`)
 | 
			
		||||
      return null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -97,10 +97,11 @@ class PlaybackSessionManager {
 | 
			
		||||
      currentTime: syncData.currentTime,
 | 
			
		||||
      progress: session.progress
 | 
			
		||||
    }
 | 
			
		||||
    var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate)
 | 
			
		||||
    var wasUpdated = user.createUpdateMediaProgress(libraryItem, itemProgressUpdate, session.episodeId)
 | 
			
		||||
    if (wasUpdated) {
 | 
			
		||||
 | 
			
		||||
      await this.db.updateEntity('user', user)
 | 
			
		||||
      var itemProgress = user.getMediaProgress(session.libraryItemId)
 | 
			
		||||
      var itemProgress = user.getMediaProgress(session.libraryItemId, session.episodeId)
 | 
			
		||||
      this.clientEmitter(user.id, 'user_item_progress_updated', {
 | 
			
		||||
        id: itemProgress.id,
 | 
			
		||||
        data: itemProgress.toJSON()
 | 
			
		||||
 | 
			
		||||
@ -440,8 +440,8 @@ class LibraryItem {
 | 
			
		||||
    return this.media.searchQuery(query)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getDirectPlayTracklist(libraryItemId) {
 | 
			
		||||
    return this.media.getDirectPlayTracklist(libraryItemId)
 | 
			
		||||
  getDirectPlayTracklist(libraryItemId, episodeId) {
 | 
			
		||||
    return this.media.getDirectPlayTracklist(libraryItemId, episodeId)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = LibraryItem
 | 
			
		||||
@ -9,6 +9,7 @@ class PlaybackSession {
 | 
			
		||||
    this.id = null
 | 
			
		||||
    this.userId = null
 | 
			
		||||
    this.libraryItemId = null
 | 
			
		||||
    this.episodeId = null
 | 
			
		||||
 | 
			
		||||
    this.mediaType = null
 | 
			
		||||
    this.mediaMetadata = null
 | 
			
		||||
@ -41,6 +42,7 @@ class PlaybackSession {
 | 
			
		||||
      sessionType: this.sessionType,
 | 
			
		||||
      userId: this.userId,
 | 
			
		||||
      libraryItemId: this.libraryItemId,
 | 
			
		||||
      episodeId: this.episodeId,
 | 
			
		||||
      mediaType: this.mediaType,
 | 
			
		||||
      mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
 | 
			
		||||
      coverPath: this.coverPath,
 | 
			
		||||
@ -60,6 +62,7 @@ class PlaybackSession {
 | 
			
		||||
      sessionType: this.sessionType,
 | 
			
		||||
      userId: this.userId,
 | 
			
		||||
      libraryItemId: this.libraryItemId,
 | 
			
		||||
      episodeId: this.episodeId,
 | 
			
		||||
      mediaType: this.mediaType,
 | 
			
		||||
      mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
 | 
			
		||||
      coverPath: this.coverPath,
 | 
			
		||||
@ -81,7 +84,8 @@ class PlaybackSession {
 | 
			
		||||
    this.sessionType = session.sessionType
 | 
			
		||||
    this.userId = session.userId
 | 
			
		||||
    this.libraryItemId = session.libraryItemId
 | 
			
		||||
    this.mediaType = session.mediaType
 | 
			
		||||
    this.episodeId = session.episodeId,
 | 
			
		||||
      this.mediaType = session.mediaType
 | 
			
		||||
    this.duration = session.duration
 | 
			
		||||
    this.playMethod = session.playMethod
 | 
			
		||||
 | 
			
		||||
@ -107,10 +111,11 @@ class PlaybackSession {
 | 
			
		||||
    return Math.max(0, Math.min(this.currentTime / this.duration, 1))
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setData(libraryItem, user) {
 | 
			
		||||
  setData(libraryItem, user, episodeId = null) {
 | 
			
		||||
    this.id = getId('play')
 | 
			
		||||
    this.userId = user.id
 | 
			
		||||
    this.libraryItemId = libraryItem.id
 | 
			
		||||
    this.episodeId = episodeId
 | 
			
		||||
    this.mediaType = libraryItem.mediaType
 | 
			
		||||
    this.mediaMetadata = libraryItem.media.metadata.clone()
 | 
			
		||||
    this.coverPath = libraryItem.media.coverPath
 | 
			
		||||
 | 
			
		||||
@ -9,12 +9,13 @@ const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
 | 
			
		||||
const AudioTrack = require('./files/AudioTrack')
 | 
			
		||||
 | 
			
		||||
class Stream extends EventEmitter {
 | 
			
		||||
  constructor(sessionId, streamPath, user, libraryItem, startTime, clientEmitter, transcodeOptions = {}) {
 | 
			
		||||
  constructor(sessionId, streamPath, user, libraryItem, episodeId, startTime, clientEmitter, transcodeOptions = {}) {
 | 
			
		||||
    super()
 | 
			
		||||
 | 
			
		||||
    this.id = sessionId
 | 
			
		||||
    this.user = user
 | 
			
		||||
    this.libraryItem = libraryItem
 | 
			
		||||
    this.episodeId = episodeId
 | 
			
		||||
    this.clientEmitter = clientEmitter
 | 
			
		||||
 | 
			
		||||
    this.transcodeOptions = transcodeOptions
 | 
			
		||||
@ -34,22 +35,28 @@ class Stream extends EventEmitter {
 | 
			
		||||
    this.isTranscodeComplete = false
 | 
			
		||||
    this.segmentsCreated = new Set()
 | 
			
		||||
    this.furthestSegmentCreated = 0
 | 
			
		||||
    // this.clientCurrentTime = 0
 | 
			
		||||
 | 
			
		||||
    this.init()
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  get isPodcast() {
 | 
			
		||||
    return this.libraryItem.mediaType === 'podcast'
 | 
			
		||||
  }
 | 
			
		||||
  get episode() {
 | 
			
		||||
    if (!this.isPodcast) return null
 | 
			
		||||
    return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
 | 
			
		||||
  }
 | 
			
		||||
  get libraryItemId() {
 | 
			
		||||
    return this.libraryItem.id
 | 
			
		||||
  }
 | 
			
		||||
  get mediaTitle() {
 | 
			
		||||
    if (this.episode) return this.episode.title || ''
 | 
			
		||||
    return this.libraryItem.media.metadata.title || ''
 | 
			
		||||
  }
 | 
			
		||||
  get totalDuration() {
 | 
			
		||||
    if (this.episode) return this.episode.duration
 | 
			
		||||
    return this.libraryItem.media.duration
 | 
			
		||||
  }
 | 
			
		||||
  get tracks() {
 | 
			
		||||
    // TODO: Podcast episode tracks
 | 
			
		||||
    if (this.episode) return this.episode.tracks
 | 
			
		||||
    return this.libraryItem.media.tracks
 | 
			
		||||
  }
 | 
			
		||||
  get tracksAudioFileType() {
 | 
			
		||||
@ -99,28 +106,16 @@ class Stream extends EventEmitter {
 | 
			
		||||
      id: this.id,
 | 
			
		||||
      userId: this.user.id,
 | 
			
		||||
      libraryItem: this.libraryItem.toJSONExpanded(),
 | 
			
		||||
      episode: this.episode ? this.episode.toJSONExpanded() : null,
 | 
			
		||||
      segmentLength: this.segmentLength,
 | 
			
		||||
      playlistPath: this.playlistPath,
 | 
			
		||||
      clientPlaylistUri: this.clientPlaylistUri,
 | 
			
		||||
      // clientCurrentTime: this.clientCurrentTime,
 | 
			
		||||
      startTime: this.startTime,
 | 
			
		||||
      segmentStartNumber: this.segmentStartNumber,
 | 
			
		||||
      isTranscodeComplete: this.isTranscodeComplete,
 | 
			
		||||
      // lastUpdate: this.clientUserAudiobookData ? this.clientUserAudiobookData.lastUpdate : 0
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  init() {
 | 
			
		||||
    // if (this.clientUserAudiobookData) {
 | 
			
		||||
    //   var timeRemaining = this.totalDuration - this.clientUserAudiobookData.currentTime
 | 
			
		||||
    //   Logger.info('[STREAM] User has progress for item', this.clientUserAudiobookData.progress, `Time Remaining: ${timeRemaining}s`)
 | 
			
		||||
    //   if (timeRemaining > 15) {
 | 
			
		||||
    //     this.startTime = this.clientUserAudiobookData.currentTime
 | 
			
		||||
    //     this.clientCurrentTime = this.startTime
 | 
			
		||||
    //   }
 | 
			
		||||
    // }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkSegmentNumberRequest(segNum) {
 | 
			
		||||
    var segStartTime = segNum * this.segmentLength
 | 
			
		||||
    if (this.startTime > segStartTime) {
 | 
			
		||||
 | 
			
		||||
@ -143,14 +143,20 @@ class Podcast {
 | 
			
		||||
    return payload || {}
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  checkHasEpisode(episodeId) {
 | 
			
		||||
    return this.episodes.some(ep => ep.id === episodeId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // Only checks container format
 | 
			
		||||
  checkCanDirectPlay(payload, epsiodeIndex = 0) {
 | 
			
		||||
    var episode = this.episodes[epsiodeIndex]
 | 
			
		||||
  checkCanDirectPlay(payload, episodeId) {
 | 
			
		||||
    var episode = this.episodes.find(ep => ep.id === episodeId)
 | 
			
		||||
    if (!episode) return false
 | 
			
		||||
    return episode.checkCanDirectPlay(payload)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getDirectPlayTracklist(libraryItemId, episodeIndex = 0) {
 | 
			
		||||
    var episode = this.episodes[episodeIndex]
 | 
			
		||||
  getDirectPlayTracklist(libraryItemId, episodeId) {
 | 
			
		||||
    var episode = this.episodes.find(ep => ep.id === episodeId)
 | 
			
		||||
    if (!episode) return false
 | 
			
		||||
    return episode.getDirectPlayTracklist(libraryItemId)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -164,6 +170,15 @@ class Podcast {
 | 
			
		||||
    this.episodes.push(pe)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setEpisodeOrder(episodeIds) {
 | 
			
		||||
    this.episodes = this.episodes.map(ep => {
 | 
			
		||||
      var indexOf = episodeIds.findIndex(id => id === ep.id)
 | 
			
		||||
      ep.index = indexOf + 1
 | 
			
		||||
      return ep
 | 
			
		||||
    })
 | 
			
		||||
    this.episodes.sort((a, b) => b.index - a.index)
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  reorderEpisodes() {
 | 
			
		||||
    var hasUpdates = false
 | 
			
		||||
    this.episodes = naturalSort(this.episodes).asc((ep) => ep.bestFilename)
 | 
			
		||||
@ -173,7 +188,12 @@ class Podcast {
 | 
			
		||||
        hasUpdates = true
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    this.episodes.sort((a, b) => b.index - a.index)
 | 
			
		||||
    return hasUpdates
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  removeEpisode(episodeId) {
 | 
			
		||||
    this.episodes = this.episodes.filter(ep => ep.id !== episodeId)
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
module.exports = Podcast
 | 
			
		||||
@ -52,10 +52,10 @@ class MediaProgress {
 | 
			
		||||
    return !this.isFinished && this.progress > 0
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  setData(libraryItemId, progress) {
 | 
			
		||||
  setData(libraryItemId, progress, episodeId = null) {
 | 
			
		||||
    this.id = libraryItemId
 | 
			
		||||
    this.libraryItemId = libraryItemId
 | 
			
		||||
    this.episodeId = progress.episodeId || null
 | 
			
		||||
    this.episodeId = episodeId
 | 
			
		||||
    this.duration = progress.duration || 0
 | 
			
		||||
    this.progress = Math.min(1, (progress.progress || 0))
 | 
			
		||||
    this.currentTime = progress.currentTime || 0
 | 
			
		||||
@ -74,11 +74,11 @@ class MediaProgress {
 | 
			
		||||
    for (const key in payload) {
 | 
			
		||||
      if (this[key] !== undefined && payload[key] !== this[key]) {
 | 
			
		||||
        if (key === 'isFinished') {
 | 
			
		||||
          if (!payload[key]) { // Updating to Not Read - Reset progress and current time
 | 
			
		||||
          if (!payload[key]) { // Updating to Not Finished - Reset progress and current time
 | 
			
		||||
            this.finishedAt = null
 | 
			
		||||
            this.progress = 0
 | 
			
		||||
            this.currentTime = 0
 | 
			
		||||
          } else { // Updating to Read
 | 
			
		||||
          } else { // Updating to Finished
 | 
			
		||||
            if (!this.finishedAt) this.finishedAt = Date.now()
 | 
			
		||||
            this.progress = 1
 | 
			
		||||
          }
 | 
			
		||||
@ -88,6 +88,16 @@ class MediaProgress {
 | 
			
		||||
        hasUpdates = true
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (this.progress >= 1 && !this.isFinished) {
 | 
			
		||||
      this.isFinished = true
 | 
			
		||||
      this.finishedAt = Date.now()
 | 
			
		||||
      this.progress = 1
 | 
			
		||||
    } else if (this.progress < 1 && this.isFinished) {
 | 
			
		||||
      this.isFinished = false
 | 
			
		||||
      this.finishedAt = null
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!this.startedAt) {
 | 
			
		||||
      this.startedAt = Date.now()
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -236,17 +236,23 @@ class User {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  getMediaProgress(libraryItemId) {
 | 
			
		||||
  getMediaProgress(libraryItemId, episodeId = null) {
 | 
			
		||||
    if (!this.mediaProgress) return null
 | 
			
		||||
    return this.mediaProgress.find(lip => lip.id === libraryItemId)
 | 
			
		||||
    return this.mediaProgress.find(lip => {
 | 
			
		||||
      if (episodeId && lip.episodeId !== episodeId) return false
 | 
			
		||||
      return lip.id === libraryItemId
 | 
			
		||||
    })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  createUpdateMediaProgress(libraryItem, updatePayload) {
 | 
			
		||||
    var itemProgress = this.mediaProgress.find(li => li.id === libraryItem.id)
 | 
			
		||||
  createUpdateMediaProgress(libraryItem, updatePayload, episodeId = null) {
 | 
			
		||||
    var itemProgress = this.mediaProgress.find(li => {
 | 
			
		||||
      if (episodeId && li.episodeId !== episodeId) return false
 | 
			
		||||
      return li.id === libraryItem.id
 | 
			
		||||
    })
 | 
			
		||||
    if (!itemProgress) {
 | 
			
		||||
      var newItemProgress = new MediaProgress()
 | 
			
		||||
 | 
			
		||||
      newItemProgress.setData(libraryItem.id, updatePayload)
 | 
			
		||||
      newItemProgress.setData(libraryItem.id, updatePayload, episodeId)
 | 
			
		||||
      this.mediaProgress.push(newItemProgress)
 | 
			
		||||
      return true
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -86,7 +86,10 @@ class ApiRouter {
 | 
			
		||||
    this.router.delete('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.removeCover.bind(this))
 | 
			
		||||
    this.router.post('/items/:id/match', LibraryItemController.middleware.bind(this), LibraryItemController.match.bind(this))
 | 
			
		||||
    this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
 | 
			
		||||
    this.router.post('/items/:id/play/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.startEpisodePlaybackSession.bind(this))
 | 
			
		||||
    this.router.patch('/items/:id/tracks', LibraryItemController.middleware.bind(this), LibraryItemController.updateTracks.bind(this))
 | 
			
		||||
    this.router.patch('/items/:id/episodes', LibraryItemController.middleware.bind(this), LibraryItemController.updateEpisodes.bind(this))
 | 
			
		||||
    this.router.delete('/items/:id/episode/:episodeId', LibraryItemController.middleware.bind(this), LibraryItemController.removeEpisode.bind(this))
 | 
			
		||||
    this.router.get('/items/:id/scan', LibraryItemController.middleware.bind(this), LibraryItemController.scan.bind(this)) // Root only
 | 
			
		||||
 | 
			
		||||
    this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
 | 
			
		||||
@ -126,6 +129,7 @@ class ApiRouter {
 | 
			
		||||
    this.router.get('/me/listening-stats', MeController.getListeningStats.bind(this))
 | 
			
		||||
    this.router.patch('/me/progress/:id', MeController.createUpdateMediaProgress.bind(this))
 | 
			
		||||
    this.router.delete('/me/progress/:id', MeController.removeMediaProgress.bind(this))
 | 
			
		||||
    this.router.patch('/me/progress/:id/:episodeId', MeController.createUpdateEpisodeMediaProgress.bind(this))
 | 
			
		||||
    this.router.patch('/me/progress/batch/update', MeController.batchUpdateMediaProgress.bind(this))
 | 
			
		||||
    this.router.post('/me/item/:id/bookmark', MeController.createBookmark.bind(this))
 | 
			
		||||
    this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this))
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user