diff --git a/client/components/app/BookListRow.vue b/client/components/app/BookListRow.vue index 4dbc09a1..60ca7924 100644 --- a/client/components/app/BookListRow.vue +++ b/client/components/app/BookListRow.vue @@ -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) diff --git a/client/components/app/BookShelfCategorized.vue b/client/components/app/BookShelfCategorized.vue index e5527eb0..23fd3d26 100644 --- a/client/components/app/BookShelfCategorized.vue +++ b/client/components/app/BookShelfCategorized.vue @@ -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 }) diff --git a/client/components/app/BookShelfRow.vue b/client/components/app/BookShelfRow.vue index 8b56a792..56c0b95d 100644 --- a/client/components/app/BookShelfRow.vue +++ b/client/components/app/BookShelfRow.vue @@ -2,7 +2,7 @@
-
+
diff --git a/client/components/app/StreamContainer.vue b/client/components/app/StreamContainer.vue index 20418103..d7f7cdbf 100644 --- a/client/components/app/StreamContainer.vue +++ b/client/components/app/StreamContainer.vue @@ -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) } } diff --git a/client/components/cards/LazyBookCard.vue b/client/components/cards/LazyBookCard.vue index 39da7c3f..9b80cb84 100644 --- a/client/components/cards/LazyBookCard.vue +++ b/client/components/cards/LazyBookCard.vue @@ -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 diff --git a/client/components/modals/item/EditModal.vue b/client/components/modals/item/EditModal.vue index 8ed90ddb..af3ab8a0 100644 --- a/client/components/modals/item/EditModal.vue +++ b/client/components/modals/item/EditModal.vue @@ -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 diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue index 25a66926..15890127 100644 --- a/client/components/tables/LibraryFilesTable.vue +++ b/client/components/tables/LibraryFilesTable.vue @@ -35,7 +35,7 @@
- download + download diff --git a/client/components/tables/TracksTable.vue b/client/components/tables/TracksTable.vue index fb888619..4df9050f 100644 --- a/client/components/tables/TracksTable.vue +++ b/client/components/tables/TracksTable.vue @@ -38,7 +38,7 @@ {{ $secondsToTimestamp(track.duration) }} - download + download diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue index 1e26b335..747b4cda 100644 --- a/client/components/tables/collection/BookTableRow.vue +++ b/client/components/tables/collection/BookTableRow.vue @@ -27,11 +27,6 @@ radio_button_unchecked
-->
-
@@ -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) diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue new file mode 100644 index 00000000..b6e3f2ff --- /dev/null +++ b/client/components/tables/podcast/EpisodeTableRow.vue @@ -0,0 +1,161 @@ + + + \ No newline at end of file diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue new file mode 100644 index 00000000..aa8b6154 --- /dev/null +++ b/client/components/tables/podcast/EpisodesTable.vue @@ -0,0 +1,99 @@ + + + + + \ No newline at end of file diff --git a/client/pages/collection/_id.vue b/client/pages/collection/_id.vue index 8492e4ff..37c9f609 100644 --- a/client/pages/collection/_id.vue +++ b/client/pages/collection/_id.vue @@ -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 + }) } } }, diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index 053273ce..e35802d8 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -7,7 +7,7 @@ -
+
@@ -96,7 +96,7 @@
-
+

Your Progress: {{ Math.round(progressPercent * 100) }}%

Finished {{ $formatDate(userProgressFinishedAt, 'MM/dd/yyyy') }}

{{ $elapsedPretty(userTimeRemaining) }} remaining

@@ -145,6 +145,8 @@ + +
@@ -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', []) diff --git a/client/players/PlayerHandler.js b/client/players/PlayerHandler.js index 9ee78e07..68caac9d 100644 --- a/client/players/PlayerHandler.js +++ b/client/players/PlayerHandler.js @@ -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) diff --git a/client/store/index.js b/client/store/index.js index 3933c1ea..64b18511 100644 --- a/client/store/index.js +++ b/client/store/index.js @@ -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' diff --git a/client/store/user.js b/client/store/user.js index d53d9e3c..b529e1f0 100644 --- a/client/store/user.js +++ b/client/store/user.js @@ -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 [] diff --git a/server/Server.js b/server/Server.js index a9369a0b..736489fb 100644 --- a/server/Server.js +++ b/server/Server.js @@ -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) diff --git a/server/controllers/LibraryController.js b/server/controllers/LibraryController.js index e174f672..28c21473 100644 --- a/server/controllers/LibraryController.js +++ b/server/controllers/LibraryController.js @@ -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) } diff --git a/server/controllers/LibraryItemController.js b/server/controllers/LibraryItemController.js index c6cac9a9..10c4e5e6 100644 --- a/server/controllers/LibraryItemController.js +++ b/server/controllers/LibraryItemController.js @@ -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 diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index acace5b0..5c3f425d 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -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 diff --git a/server/managers/PlaybackSessionManager.js b/server/managers/PlaybackSessionManager.js index e319b2ee..20cb8c57 100644 --- a/server/managers/PlaybackSessionManager.js +++ b/server/managers/PlaybackSessionManager.js @@ -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() diff --git a/server/objects/LibraryItem.js b/server/objects/LibraryItem.js index 72335283..d5c8672a 100644 --- a/server/objects/LibraryItem.js +++ b/server/objects/LibraryItem.js @@ -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 \ No newline at end of file diff --git a/server/objects/PlaybackSession.js b/server/objects/PlaybackSession.js index e1b0e8dc..990dfcdd 100644 --- a/server/objects/PlaybackSession.js +++ b/server/objects/PlaybackSession.js @@ -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 diff --git a/server/objects/Stream.js b/server/objects/Stream.js index 1abb592c..abe61fde 100644 --- a/server/objects/Stream.js +++ b/server/objects/Stream.js @@ -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) { diff --git a/server/objects/mediaTypes/Podcast.js b/server/objects/mediaTypes/Podcast.js index 8bb716f7..9654fe64 100644 --- a/server/objects/mediaTypes/Podcast.js +++ b/server/objects/mediaTypes/Podcast.js @@ -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 \ No newline at end of file diff --git a/server/objects/user/MediaProgress.js b/server/objects/user/MediaProgress.js index 056dfe06..49c8183c 100644 --- a/server/objects/user/MediaProgress.js +++ b/server/objects/user/MediaProgress.js @@ -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() } diff --git a/server/objects/user/User.js b/server/objects/user/User.js index bf0948ea..a471a256 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -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 } diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 4891691e..6545b104 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -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))