Add playing podcast episodes, episode progress, podcast page, podcast home page shelves

This commit is contained in:
advplyr 2022-03-26 17:41:26 -05:00
parent e32d05ea27
commit 0e665e2091
28 changed files with 526 additions and 82 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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

View file

@ -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()
}

View file

@ -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
}

View file

@ -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))