New data model play media entity, PlaybackSessionManager

This commit is contained in:
advplyr 2022-03-17 19:10:47 -05:00
parent 1cf9e85272
commit 099ae7c776
54 changed files with 841 additions and 902 deletions

View file

@ -35,7 +35,6 @@ class Db {
this.libraryItems = []
this.users = []
this.sessions = []
this.libraries = []
this.settings = []
this.collections = []
@ -263,7 +262,7 @@ class Db {
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].concat(entities)
if (this[arrayKey]) this[arrayKey] = this[arrayKey].concat(entities)
return true
}).catch((error) => {
Logger.error(`[DB] Failed to insert ${entityName}`, error)
@ -277,7 +276,7 @@ class Db {
Logger.debug(`[DB] Inserted ${results.inserted} ${entityName}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey].push(entity)
if (this[arrayKey]) this[arrayKey].push(entity)
return true
}).catch((error) => {
Logger.error(`[DB] Failed to insert ${entityName}`, error)
@ -294,10 +293,12 @@ class Db {
}).then((results) => {
Logger.debug(`[DB] Updated ${entityName}: ${results.updated}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].map(e => {
if (entityIds.includes(e.id)) return entities.find(_e => _e.id === e.id)
return e
})
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => {
if (entityIds.includes(e.id)) return entities.find(_e => _e.id === e.id)
return e
})
}
return true
}).catch((error) => {
Logger.error(`[DB] Update ${entityName} Failed: ${error}`)
@ -321,9 +322,11 @@ class Db {
}
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
})
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].map(e => {
return e.id === entity.id ? entity : e
})
}
return true
}).catch((error) => {
Logger.error(`[DB] Update entity ${entityName} Failed: ${error}`)
@ -336,9 +339,11 @@ class Db {
return entityDb.delete((record) => record.id === entityId).then((results) => {
Logger.debug(`[DB] Deleted entity ${entityName}: ${results.deleted}`)
var arrayKey = this.getEntityArrayKey(entityName)
this[arrayKey] = this[arrayKey].filter(e => {
return e.id !== entityId
})
if (this[arrayKey]) {
this[arrayKey] = this[arrayKey].filter(e => {
return e.id !== entityId
})
}
}).catch((error) => {
Logger.error(`[DB] Remove entity ${entityName} Failed: ${error}`)
})

View file

@ -1,5 +1,8 @@
const Path = require('path')
const { PlayMethod } = require('./utils/constants')
const PlaybackSession = require('./objects/PlaybackSession')
const Stream = require('./objects/Stream')
const Logger = require('./Logger')
class PlaybackSessionManager {
constructor(db, emitter, clientEmitter) {
@ -11,25 +14,120 @@ class PlaybackSessionManager {
this.sessions = []
}
async startSessionRequest(req, res) {
var user = req.user
var libraryItem = req.libraryItem
var options = req.query || {}
const session = await this.startSession(user, libraryItem, options)
res.json(session)
getSession(sessionId) {
return this.sessions.find(s => s.id === sessionId)
}
getUserSession(userId) {
return this.sessions.find(s => s.userId === userId)
}
getStream(sessionId) {
var session = this.getSession(sessionId)
return session ? session.stream : null
}
async startSession(user, libraryItem, options) {
// TODO: Determine what play method to use and setup playback session
// temporary client can pass direct=1 in query string for direct play
if (options.direct) {
var tracks = libraryItem.media.getDirectPlayTracklist(options)
async startSessionRequest(user, libraryItem, mediaEntity, options, res) {
const session = await this.startSession(user, libraryItem, mediaEntity, options)
res.json(session.toJSONForClient())
}
async syncSessionRequest(user, session, payload, res) {
await this.syncSession(user, session, payload)
res.json(session.toJSONForClient())
}
async closeSessionRequest(user, session, syncData, res) {
await this.closeSession(user, session, syncData)
res.sendStatus(200)
}
async startSession(user, libraryItem, mediaEntity, options) {
var shouldDirectPlay = options.forceDirectPlay || (!options.forceTranscode && mediaEntity.checkCanDirectPlay(options))
const userProgress = user.getLibraryItemProgress(libraryItem.id)
var userStartTime = 0
if (userProgress) userStartTime = userProgress.currentTime || 0
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, mediaEntity, user)
var audioTracks = []
if (shouldDirectPlay) {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting direct play session for media entity "${mediaEntity.id}"`)
audioTracks = mediaEntity.getDirectPlayTracklist(libraryItem.id)
newPlaybackSession.playMethod = PlayMethod.DIRECTPLAY
} else {
Logger.debug(`[PlaybackSessionManager] "${user.username}" starting stream session for media entity "${mediaEntity.id}"`)
var stream = new Stream(newPlaybackSession.id, this.StreamsPath, user, libraryItem, mediaEntity, userStartTime, this.clientEmitter.bind(this))
await stream.generatePlaylist()
audioTracks = [stream.getAudioTrack()]
newPlaybackSession.stream = stream
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
stream.on('closed', () => {
Logger.debug(`[PlaybackSessionManager] Stream closed for session "${newPlaybackSession.id}"`)
newPlaybackSession.stream = null
})
}
const newPlaybackSession = new PlaybackSession()
newPlaybackSession.setData(libraryItem, user)
newPlaybackSession.currentTime = userStartTime
newPlaybackSession.audioTracks = audioTracks
// Will save on the first sync
user.currentSessionId = newPlaybackSession.id
this.sessions.push(newPlaybackSession)
this.emitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
return newPlaybackSession
}
async syncSession(user, session, syncData) {
session.currentTime = syncData.currentTime
session.addListeningTime(syncData.timeListened)
Logger.debug(`[PlaybackSessionManager] syncSession "${session.id}" | Total Time Listened: ${session.timeListening}`)
const itemProgressUpdate = {
currentTime: syncData.currentTime,
progress: session.progress
}
var wasUpdated = user.createUpdateLibraryItemProgress(session.libraryItemId, itemProgressUpdate)
if (wasUpdated) {
await this.db.updateEntity('user', user)
var itemProgress = user.getLibraryItemProgress(session.libraryItemId)
this.clientEmitter(user.id, 'user_item_progress_updated', {
id: itemProgress.id,
data: itemProgress.toJSON()
})
}
this.saveSession(session)
}
async closeSession(user, session, syncData = null) {
if (syncData) {
await this.syncSession(user, session, syncData)
} else {
await this.saveSession(session)
}
Logger.debug(`[PlaybackSessionManager] closeSession "${session.id}"`)
this.emitter('user_stream_update', user.toJSONForPublic(this.sessions, this.db.libraryItems))
return this.removeSession(session.id)
}
saveSession(session) {
if (session.lastSave) {
return this.db.updateEntity('session', session)
} else {
session.lastSave = Date.now()
return this.db.insertEntity('session', session)
}
}
async removeSession(sessionId) {
var session = this.sessions.find(s => s.id === sessionId)
if (!session) return
if (session.stream) {
await session.stream.close()
}
this.sessions = this.sessions.filter(s => s.id !== sessionId)
Logger.debug(`[PlaybackSessionManager] Removed session "${sessionId}"`)
}
}
module.exports = PlaybackSessionManager

View file

@ -11,7 +11,6 @@ const { version } = require('../package.json')
// Utils
const { ScanResult } = require('./utils/constants')
const filePerms = require('./utils/filePerms')
const { secondsToTimestamp } = require('./utils/index')
const dbMigration = require('./utils/dbMigration')
const Logger = require('./Logger')
@ -22,9 +21,9 @@ const Scanner = require('./scanner/Scanner')
const Db = require('./Db')
const BackupManager = require('./BackupManager')
const LogManager = require('./LogManager')
const ApiController = require('./ApiController')
const HlsController = require('./HlsController')
// const StreamManager = require('./objects/legacy/StreamManager')
const ApiRouter = require('./routers/ApiRouter')
const HlsRouter = require('./routers/HlsRouter')
const StaticRouter = require('./routers/StaticRouter')
const PlaybackSessionManager = require('./PlaybackSessionManager')
const DownloadManager = require('./DownloadManager')
const CoverController = require('./CoverController')
@ -58,12 +57,13 @@ class Server {
this.watcher = new Watcher()
this.coverController = new CoverController(this.db, this.cacheManager)
this.scanner = new Scanner(this.db, this.coverController, this.emitter.bind(this))
this.playbackSessionManager = new PlaybackSessionManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
// this.streamManager = new StreamManager(this.db, this.emitter.bind(this), this.clientEmitter.bind(this))
this.downloadManager = new DownloadManager(this.db)
this.apiController = new ApiController(this.db, this.auth, this.scanner, this.playbackSessionManager, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsController = new HlsController(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
// Routers
this.apiRouter = new ApiRouter(this.db, this.auth, this.scanner, this.playbackSessionManager, this.downloadManager, this.coverController, this.backupManager, this.watcher, this.cacheManager, this.emitter.bind(this), this.clientEmitter.bind(this))
this.hlsRouter = new HlsRouter(this.db, this.auth, this.playbackSessionManager, this.emitter.bind(this))
this.staticRouter = new StaticRouter(this.db)
Logger.logManager = this.logManager
@ -76,7 +76,7 @@ class Server {
get usersOnline() {
// TODO: Map open user sessions
return Object.values(this.clients).filter(c => c.user).map(client => {
return client.user.toJSONForPublic([])
return client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems)
})
}
@ -169,41 +169,9 @@ class Server {
// Static folder
app.use(express.static(Path.join(global.appRoot, 'static')))
app.use('/api', this.authMiddleware.bind(this), this.apiController.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsController.router)
// Static file routes
app.get('/lib/:library/:folder/*', this.authMiddleware.bind(this), (req, res) => {
var library = this.db.libraries.find(lib => lib.id === req.params.library)
if (!library) return res.sendStatus(404)
var folder = library.folders.find(fol => fol.id === req.params.folder)
if (!folder) return res.status(404).send('Folder not found')
var remainingPath = req.params['0']
var fullPath = Path.join(folder.fullPath, remainingPath)
res.sendFile(fullPath)
})
// Book static file routes
// LEGACY
app.get('/s/book/:id/*', this.authMiddleware.bind(this), (req, res) => {
var audiobook = this.db.audiobooks.find(ab => ab.id === req.params.id)
if (!audiobook) return res.status(404).send('Book not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = Path.join(audiobook.fullPath, remainingPath)
res.sendFile(fullPath)
})
// Library Item static file routes
app.get('/s/item/:id/*', this.authMiddleware.bind(this), (req, res) => {
var item = this.db.libraryItems.find(ab => ab.id === req.params.id)
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = Path.join(item.path, remainingPath)
res.sendFile(fullPath)
})
app.use('/api', this.authMiddleware.bind(this), this.apiRouter.router)
app.use('/hls', this.authMiddleware.bind(this), this.hlsRouter.router)
app.use('/s', this.authMiddleware.bind(this), this.staticRouter.router)
// EBook static file routes
app.get('/ebook/:library/:folder/*', (req, res) => {
@ -267,14 +235,6 @@ class Server {
socket.on('scan_item', (libraryItemId) => this.scanLibraryItem(socket, libraryItemId))
socket.on('save_metadata', (libraryItemId) => this.saveMetadata(socket, libraryItemId))
// Streaming (only still used in the mobile app)
// socket.on('open_stream', (audiobookId) => this.streamManager.openStreamSocketRequest(socket, audiobookId))
// socket.on('close_stream', () => this.streamManager.closeStreamRequest(socket))
// socket.on('stream_sync', (syncData) => this.streamManager.streamSync(socket, syncData))
// Used to sync when playing local book on mobile, will be moved to API route
// socket.on('progress_update', (payload) => this.audiobookProgressUpdate(socket, payload))
// Downloading
socket.on('download', (payload) => this.downloadManager.downloadSocketRequest(socket, payload))
socket.on('remove_download', (downloadId) => this.downloadManager.removeSocketRequest(socket, downloadId))
@ -303,7 +263,7 @@ class Server {
delete this.clients[socket.id]
} else {
Logger.debug('[Server] User Offline ' + _client.user.username)
this.io.emit('user_offline', _client.user.toJSONForPublic([]))
this.io.emit('user_offline', _client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
const disconnectTime = Date.now() - _client.connected_at
Logger.info(`[Server] Socket ${socket.id} disconnected from client "${_client.user.username}" after ${disconnectTime}ms`)
@ -487,11 +447,10 @@ class Server {
if (client.user) {
Logger.debug('[Server] User Offline ' + client.user.username)
this.io.emit('user_offline', client.user.toJSONForPublic(null))
this.io.emit('user_offline', client.user.toJSONForPublic(null, this.db.libraryItems))
}
delete this.clients[socketId].user
delete this.clients[socketId].stream
if (clientSocket && clientSocket.sheepClient) delete this.clients[socketId].socket.sheepClient
} else if (socketId) {
Logger.warn(`[Server] No client for socket ${socketId}`)
@ -604,19 +563,23 @@ class Server {
return
}
// Check if user has stream open
if (client.user.stream) {
Logger.info('User has stream open already', client.user.stream)
// client.stream = this.streamManager.getStream(client.user.stream)
// if (!client.stream) {
// Logger.error('Invalid user stream id', client.user.stream)
// this.streamManager.removeOrphanStreamFiles(client.user.stream)
// await this.db.updateUserStream(client.user.id, null)
// }
// Check if user has session open
var session = this.playbackSessionManager.getUserSession(user.id)
if (session) {
Logger.debug(`[Server] User Online "${client.user.username}" with session open "${session.id}"`)
session = session.toJSONForClient()
var sessionLibraryItem = this.db.libraryItems.find(li => li.id === session.libraryItemId)
if (!sessionLibraryItem) {
Logger.error(`[Server] Library Item for session "${session.id}" does not exist "${session.libraryItemId}"`)
this.playbackSessionManager.removeSession(session.id)
session = null
} else {
session.libraryItem = sessionLibraryItem.toJSONExpanded()
}
} else {
Logger.debug(`[Server] User Online ${client.user.username}`)
}
Logger.debug(`[Server] User Online ${client.user.username}`)
this.io.emit('user_online', client.user.toJSONForPublic([]))
this.io.emit('user_online', client.user.toJSONForPublic(this.playbackSessionManager.sessions, this.db.libraryItems))
user.lastSeen = Date.now()
await this.db.updateEntity('user', user)
@ -627,7 +590,7 @@ class Server {
metadataPath: global.MetadataPath,
configPath: global.ConfigPath,
user: client.user.toJSONForBrowser(),
stream: client.stream || null,
session,
librariesScanning: this.scanner.librariesScanning,
backups: (this.backupManager.backups || []).map(b => b.toJSON())
}

View file

@ -1,57 +0,0 @@
const Logger = require('../Logger')
class AudiobookController {
constructor() { }
async findOne(req, res) {
if (req.query.expanded == 1) return res.json(req.audiobook.toJSONExpanded())
return res.json(req.audiobook)
}
async findWithItem(req, res) {
if (req.query.expanded == 1) {
return res.json({
libraryItem: req.libraryItem.toJSONExpanded(),
audiobook: req.audiobook.toJSONExpanded()
})
}
res.json({
libraryItem: req.libraryItem.toJSON(),
audiobook: req.audiobook.toJSON()
})
}
// PATCH: api/audiobooks/:id/tracks
async updateTracks(req, res) {
var libraryItem = req.libraryItem
var audiobook = req.audiobook
var orderedFileData = req.body.orderedFileData
audiobook.updateAudioTracks(orderedFileData)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
middleware(req, res, next) {
var audiobook = null
var libraryItem = this.db.libraryItems.find(li => {
if (li.mediaType != 'book') return false
audiobook = li.media.getAudiobookById(req.params.id)
return !!audiobook
})
if (!audiobook) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[AudiobookController] User attempted to delete without permission`, req.user)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
Logger.warn('[AudiobookController] User attempted to update without permission', req.user)
return res.sendStatus(403)
}
req.libraryItem = libraryItem
req.audiobook = audiobook
next()
}
}
module.exports = new AudiobookController()

View file

@ -5,7 +5,7 @@ class BackupController {
async delete(req, res) {
if (!req.user.isRoot) {
Logger.error(`[ApiController] Non-Root user attempting to delete backup`, req.user)
Logger.error(`[BackupController] Non-Root user attempting to delete backup`, req.user)
return res.sendStatus(403)
}
var backup = this.backupManager.backups.find(b => b.id === req.params.id)
@ -18,11 +18,11 @@ class BackupController {
async upload(req, res) {
if (!req.user.isRoot) {
Logger.error(`[ApiController] Non-Root user attempting to upload backup`, req.user)
Logger.error(`[BackupController] Non-Root user attempting to upload backup`, req.user)
return res.sendStatus(403)
}
if (!req.files.file) {
Logger.error('[ApiController] Upload backup invalid')
Logger.error('[BackupController] Upload backup invalid')
return res.sendStatus(500)
}
this.backupManager.uploadBackup(req, res)

View file

@ -344,7 +344,7 @@ class LibraryController {
// PATCH: Change the order of libraries
async reorder(req, res) {
if (!req.user.isRoot) {
Logger.error('[ApiController] ReorderLibraries invalid user', req.user)
Logger.error('[LibraryController] ReorderLibraries invalid user', req.user)
return res.sendStatus(403)
}
@ -353,7 +353,7 @@ class LibraryController {
for (let i = 0; i < orderdata.length; i++) {
var library = this.db.libraries.find(lib => lib.id === orderdata[i].id)
if (!library) {
Logger.error(`[ApiController] Invalid library not found in reorder ${orderdata[i].id}`)
Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)
return res.sendStatus(500)
}
if (library.update({ displayOrder: orderdata[i].newOrder })) {
@ -363,9 +363,9 @@ class LibraryController {
}
if (hasUpdates) {
Logger.info(`[ApiController] Updated library display orders`)
Logger.info(`[LibraryController] Updated library display orders`)
} else {
Logger.info(`[ApiController] Library orders were up to date`)
Logger.info(`[LibraryController] Library orders were up to date`)
}
var libraries = this.db.libraries.map(lib => lib.toJSON())

View file

@ -142,9 +142,16 @@ class LibraryItemController {
res.sendStatus(500)
}
// GET: api/items/:id/play
// POST: api/items/:id/play
startPlaybackSession(req, res) {
res.sendStatus(200)
var playbackMediaEntity = req.libraryItem.getPlaybackMediaEntity()
if (!playbackMediaEntity) {
Logger.error(`[LibraryItemController] startPlaybackSession no playback media entity ${req.libraryItem.id}`)
return res.sendStatus(404)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, playbackMediaEntity, options, res)
}
// POST api/items/:id/match

View file

@ -0,0 +1,71 @@
const Logger = require('../Logger')
class MediaEntityController {
constructor() { }
async findOne(req, res) {
if (req.query.expanded == 1) return res.json(req.mediaEntity.toJSONExpanded())
return res.json(req.mediaEntity)
}
async findWithItem(req, res) {
if (req.query.expanded == 1) {
return res.json({
libraryItem: req.libraryItem.toJSONExpanded(),
mediaEntity: req.mediaEntity.toJSONExpanded()
})
}
res.json({
libraryItem: req.libraryItem.toJSON(),
mediaEntity: req.mediaEntity.toJSON()
})
}
// PATCH: api/entities/:id/tracks
async updateTracks(req, res) {
var libraryItem = req.libraryItem
var mediaEntity = req.mediaEntity
var orderedFileData = req.body.orderedFileData
if (!mediaEntity.updateAudioTracks) {
Logger.error(`[MediaEntityController] updateTracks invalid media entity ${mediaEntity.id}`)
return res.sendStatus(500)
}
mediaEntity.updateAudioTracks(orderedFileData)
await this.db.updateLibraryItem(libraryItem)
this.emitter('item_updated', libraryItem.toJSONExpanded())
res.json(libraryItem.toJSON())
}
// POST: api/entities/:id/play
startPlaybackSession(req, res) {
if (!req.mediaEntity.isPlaybackMediaEntity) {
Logger.error(`[MediaEntityController] startPlaybackSession invalid media entity ${req.mediaEntity.id}`)
return res.sendStatus(500)
}
const options = req.body || {}
this.playbackSessionManager.startSessionRequest(req.user, req.libraryItem, req.mediaEntity, options, res)
}
middleware(req, res, next) {
var mediaEntity = null
var libraryItem = this.db.libraryItems.find(li => {
if (li.mediaType != 'book') return false
mediaEntity = li.media.getMediaEntityById(req.params.id)
return !!mediaEntity
})
if (!mediaEntity) return res.sendStatus(404)
if (req.method == 'DELETE' && !req.user.canDelete) {
Logger.warn(`[MediaEntityController] User attempted to delete without permission`, req.user)
return res.sendStatus(403)
} else if ((req.method == 'PATCH' || req.method == 'POST') && !req.user.canUpdate) {
Logger.warn('[MediaEntityController] User attempted to update without permission', req.user)
return res.sendStatus(403)
}
req.mediaEntity = mediaEntity
req.libraryItem = libraryItem
next()
}
}
module.exports = new MediaEntityController()

View file

@ -0,0 +1,33 @@
const Logger = require('../Logger')
class SessionController {
constructor() { }
async findOne(req, res) {
return res.json(req.session)
}
// POST: api/session/:id/sync
sync(req, res) {
this.playbackSessionManager.syncSessionRequest(req.user, req.session, req.body, res)
}
// POST: api/session/:id/close
close(req, res) {
this.playbackSessionManager.closeSessionRequest(req.user, req.session, req.body, res)
}
middleware(req, res, next) {
var playbackSession = this.playbackSessionManager.getSession(req.params.id)
if (!playbackSession) return res.sendStatus(404)
if (playbackSession.userId !== req.user.id) {
Logger.error(`[SessionController] User "${req.user.username}" attempting to access session belonging to another user "${req.params.id}"`)
return res.sendStatus(404)
}
req.session = playbackSession
next()
}
}
module.exports = new SessionController()

View file

@ -6,6 +6,26 @@ const { getId } = require('../utils/index')
class UserController {
constructor() { }
findAll(req, res) {
if (!req.user.isRoot) return res.sendStatus(403)
var users = this.db.users.map(u => this.userJsonWithItemProgressDetails(u))
res.json(users)
}
findOne(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to get user', req.user)
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
res.json(this.userJsonWithItemProgressDetails(user))
}
async create(req, res) {
if (!req.user.isRoot) {
Logger.warn('Non-root user attempted to create user', req.user)
@ -36,26 +56,6 @@ class UserController {
}
}
findAll(req, res) {
if (!req.user.isRoot) return res.sendStatus(403)
var users = this.db.users.map(u => this.userJsonWithBookProgressDetails(u))
res.json(users)
}
findOne(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to get user', req.user)
return res.sendStatus(403)
}
var user = this.db.users.find(u => u.id === req.params.id)
if (!user) {
return res.sendStatus(404)
}
res.json(this.userJsonWithBookProgressDetails(user))
}
async update(req, res) {
if (!req.user.isRoot) {
Logger.error('User other than root attempting to update user', req.user)

View file

@ -434,8 +434,8 @@ class LibraryItem {
return this.media.searchQuery(query)
}
getDirectPlayTracklist(options) {
return this.media.getDirectPlayTracklist(options)
getPlaybackMediaEntity() {
return this.media.getPlaybackMediaEntity()
}
}
module.exports = LibraryItem

View file

@ -9,8 +9,11 @@ class PlaybackSession {
this.id = null
this.userId = null
this.libraryItemId = null
this.mediaEntityId = null
this.mediaType = null
this.mediaMetadata = null
this.duration = null
this.playMethod = null
@ -21,6 +24,12 @@ class PlaybackSession {
this.startedAt = null
this.updatedAt = null
// Not saved in DB
this.lastSave = 0
this.audioTracks = []
this.currentTime = 0
this.stream = null
if (session) {
this.construct(session)
}
@ -32,8 +41,10 @@ class PlaybackSession {
sessionType: this.sessionType,
userId: this.userId,
libraryItemId: this.libraryItemId,
mediaEntityId: this.mediaEntityId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
duration: this.duration,
playMethod: this.playMethod,
date: this.date,
dayOfWeek: this.dayOfWeek,
@ -43,12 +54,35 @@ class PlaybackSession {
}
}
toJSONForClient() {
return {
id: this.id,
sessionType: this.sessionType,
userId: this.userId,
libraryItemId: this.libraryItemId,
mediaEntityId: this.mediaEntityId,
mediaType: this.mediaType,
mediaMetadata: this.mediaMetadata ? this.mediaMetadata.toJSON() : null,
duration: this.duration,
playMethod: this.playMethod,
date: this.date,
dayOfWeek: this.dayOfWeek,
timeListening: this.timeListening,
lastUpdate: this.lastUpdate,
updatedAt: this.updatedAt,
audioTracks: this.audioTracks.map(at => at.toJSON()),
currentTime: this.currentTime
}
}
construct(session) {
this.id = session.id
this.sessionType = session.sessionType
this.userId = session.userId
this.libraryItemId = session.libraryItemId
this.mediaEntityId = session.mediaEntityId
this.mediaType = session.mediaType
this.duration = session.duration
this.playMethod = session.playMethod
this.mediaMetadata = null
@ -68,30 +102,38 @@ class PlaybackSession {
this.updatedAt = session.updatedAt || null
}
setData(libraryItem, user) {
this.id = getId('ls')
get progress() { // Value between 0 and 1
if (!this.duration) return 0
return Math.max(0, Math.min(this.currentTime / this.duration, 1))
}
setData(libraryItem, mediaEntity, user) {
this.id = getId('play')
this.userId = user.id
this.libraryItemId = libraryItem.id
this.mediaEntityId = mediaEntity.id
this.mediaType = libraryItem.mediaType
this.mediaMetadata = libraryItem.media.metadata.clone()
this.playMethod = PlayMethod.TRANSCODE
this.duration = mediaEntity.duration
this.timeListening = 0
this.date = date.format(new Date(), 'YYYY-MM-DD')
this.dayOfWeek = date.format(new Date(), 'dddd')
this.startedAt = Date.now()
this.updatedAt = Date.now()
}
addListeningTime(timeListened) {
if (timeListened && !isNaN(timeListened)) {
if (!this.date) {
// Set date info on first listening update
this.date = date.format(new Date(), 'YYYY-MM-DD')
this.dayOfWeek = date.format(new Date(), 'dddd')
}
if (!timeListened || isNaN(timeListened)) return
this.timeListening += timeListened
this.updatedAt = Date.now()
if (!this.date) {
// Set date info on first listening update
this.date = date.format(new Date(), 'YYYY-MM-DD')
this.dayOfWeek = date.format(new Date(), 'dddd')
}
this.timeListening += timeListened
this.updatedAt = Date.now()
}
// New date since start of listening session

View file

@ -6,16 +6,17 @@ const Logger = require('../Logger')
const { getId, secondsToTimestamp } = require('../utils/index')
const { writeConcatFile } = require('../utils/ffmpegHelpers')
const hlsPlaylistGenerator = require('../utils/hlsPlaylistGenerator')
const UserListeningSession = require('./legacy/UserListeningSession')
const AudioTrack = require('./files/AudioTrack')
class Stream extends EventEmitter {
constructor(streamPath, client, libraryItem, transcodeOptions = {}) {
constructor(sessionId, streamPath, user, libraryItem, mediaEntity, startTime, clientEmitter, transcodeOptions = {}) {
super()
this.id = getId('str')
this.client = client
this.id = sessionId
this.user = user
this.libraryItem = libraryItem
this.mediaEntity = mediaEntity
this.clientEmitter = clientEmitter
this.transcodeOptions = transcodeOptions
@ -25,7 +26,7 @@ class Stream extends EventEmitter {
this.concatFilesPath = Path.join(this.streamPath, 'files.txt')
this.playlistPath = Path.join(this.streamPath, 'output.m3u8')
this.finalPlaylistPath = Path.join(this.streamPath, 'final-output.m3u8')
this.startTime = 0
this.startTime = startTime
this.ffmpeg = null
this.loop = null
@ -34,53 +35,49 @@ class Stream extends EventEmitter {
this.isTranscodeComplete = false
this.segmentsCreated = new Set()
this.furthestSegmentCreated = 0
this.clientCurrentTime = 0
this.listeningSession = new UserListeningSession()
this.listeningSession.setData(libraryItem, client.user)
// this.clientCurrentTime = 0
this.init()
}
get socket() {
return this.client ? this.client.socket || null : null
}
get libraryItemId() {
return this.libraryItem.id
}
get mediaTitle() {
return this.libraryItem.media.metadata.title || ''
}
get mediaEntityName() {
return this.mediaEntity.name
}
get itemTitle() {
return this.libraryItem ? this.libraryItem.media.metadata.title : null
return `${this.mediaTitle} (${this.mediaEntityName})`
}
get totalDuration() {
return this.libraryItem.media.duration
return this.mediaEntity.duration
}
get tracks() {
return this.mediaEntity.tracks
}
get tracksAudioFileType() {
if (!this.tracks.length) return null
return this.tracks[0].metadata.ext.toLowerCase().slice(1)
return this.tracks[0].metadata.format
}
get userToken() {
return this.user.token
}
// Fmp4 does not work on iOS devices: https://github.com/advplyr/audiobookshelf-app/issues/85
// Workaround: Force AAC transcode for FLAC
get hlsSegmentType() {
return 'mpegts'
// var hasFlac = this.tracks.find(t => t.ext.toLowerCase() === '.flac')
// return hasFlac ? 'fmp4' : 'mpegts'
}
get segmentBasename() {
if (this.hlsSegmentType === 'fmp4') return 'output-%d.m4s'
return 'output-%d.ts'
}
get segmentStartNumber() {
if (!this.startTime) return 0
return Math.floor(Math.max(this.startTime - this.maxSeekBackTime, 0) / this.segmentLength)
}
get numSegments() {
var numSegs = Math.floor(this.totalDuration / this.segmentLength)
if (this.totalDuration - (numSegs * this.segmentLength) > 0) {
@ -88,41 +85,17 @@ class Stream extends EventEmitter {
}
return numSegs
}
get tracks() {
return this.libraryItem.media.tracks
}
get clientUser() {
return this.client ? this.client.user || {} : null
}
get userToken() {
return this.clientUser ? this.clientUser.token : null
}
get clientUserAudiobooks() {
return this.client ? this.clientUser.audiobooks || {} : null
}
get clientUserAudiobookData() {
return this.client ? this.clientUserAudiobooks[this.libraryItemId] : null
}
get clientPlaylistUri() {
return `/hls/${this.id}/output.m3u8`
}
get clientProgress() {
if (!this.clientCurrentTime) return 0
var prog = Math.min(1, this.clientCurrentTime / this.totalDuration)
return Number(prog.toFixed(3))
}
// get clientProgress() {
// if (!this.clientCurrentTime) return 0
// var prog = Math.min(1, this.clientCurrentTime / this.totalDuration)
// return Number(prog.toFixed(3))
// }
get isAACEncodable() {
return ['mp4', 'm4a', 'm4b'].includes(this.tracksAudioFileType)
}
get transcodeForceAAC() {
return !!this.transcodeOptions.forceAAC
}
@ -130,29 +103,28 @@ class Stream extends EventEmitter {
toJSON() {
return {
id: this.id,
clientId: this.client.id,
userId: this.client.user.id,
userId: this.user.id,
libraryItem: this.libraryItem.toJSONExpanded(),
segmentLength: this.segmentLength,
playlistPath: this.playlistPath,
clientPlaylistUri: this.clientPlaylistUri,
clientCurrentTime: this.clientCurrentTime,
// clientCurrentTime: this.clientCurrentTime,
startTime: this.startTime,
segmentStartNumber: this.segmentStartNumber,
isTranscodeComplete: this.isTranscodeComplete,
lastUpdate: this.clientUserAudiobookData ? this.clientUserAudiobookData.lastUpdate : 0
// 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
}
}
// 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) {
@ -175,39 +147,6 @@ class Stream extends EventEmitter {
return false
}
syncStream({ timeListened, currentTime }) {
var syncLog = ''
// Set user current time
if (currentTime !== null && !isNaN(currentTime)) {
syncLog = `Update client current time ${secondsToTimestamp(currentTime)}`
this.clientCurrentTime = currentTime
}
// Update user listening session
var saveListeningSession = false
if (timeListened && !isNaN(timeListened)) {
// Check if listening session should roll to next day
if (this.listeningSession.checkDateRollover()) {
if (!this.clientUser) {
Logger.error(`[Stream] Sync stream invalid client user`)
return null
}
this.listeningSession = new UserListeningSession()
this.listeningSession.setData(this.libraryItem, this.clientUser)
Logger.debug(`[Stream] Listening session rolled to next day`)
}
this.listeningSession.addListeningTime(timeListened)
if (syncLog) syncLog += ' | '
syncLog += `Add listening time ${timeListened}s, Total time listened ${this.listeningSession.timeListening}s`
saveListeningSession = true
}
Logger.debug('[Stream]', syncLog)
return saveListeningSession ? this.listeningSession : null
}
async generatePlaylist() {
fs.ensureDirSync(this.streamPath)
await hlsPlaylistGenerator(this.playlistPath, 'output', this.totalDuration, this.segmentLength, this.hlsSegmentType, this.userToken)
@ -234,10 +173,8 @@ class Stream extends EventEmitter {
if (this.segmentsCreated.size > 6 && !this.isClientInitialized) {
this.isClientInitialized = true
if (this.socket) {
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.socket.emit('stream_open', this.toJSON())
}
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.clientEmit('stream_open', this.toJSON())
}
var chunks = []
@ -270,33 +207,27 @@ class Stream extends EventEmitter {
Logger.info('[STREAM-CHECK] Check Files', this.segmentsCreated.size, 'of', this.numSegments, perc, `Furthest Segment: ${this.furthestSegmentCreated}`)
// Logger.debug('[STREAM-CHECK] Chunks', chunks.join(', '))
if (this.socket) {
this.socket.emit('stream_progress', {
stream: this.id,
percent: perc,
chunks,
numSegments: this.numSegments
})
}
this.clientEmit('stream_progress', {
stream: this.id,
percent: perc,
chunks,
numSegments: this.numSegments
})
} catch (error) {
Logger.error('Failed checking files', error)
}
}
startLoop() {
if (this.socket) {
this.socket.emit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })
}
this.clientEmit('stream_progress', { stream: this.id, chunks: [], numSegments: 0, percent: '0%' })
clearInterval(this.loop)
var intervalId = setInterval(() => {
if (!this.isTranscodeComplete) {
this.checkFiles()
} else {
if (this.socket) {
Logger.info(`[Stream] ${this.itemTitle} sending stream_ready`)
this.socket.emit('stream_ready')
}
Logger.info(`[Stream] ${this.itemTitle} sending stream_ready`)
this.clientEmit('stream_ready')
clearInterval(intervalId)
}
}, 2000)
@ -409,10 +340,10 @@ class Stream extends EventEmitter {
// For very small fast load
if (!this.isClientInitialized) {
this.isClientInitialized = true
if (this.socket) {
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.socket.emit('stream_open', this.toJSON())
}
Logger.info(`[STREAM] ${this.id} notifying client that stream is ready`)
this.clientEmit('stream_open', this.toJSON())
}
this.isTranscodeComplete = true
this.ffmpeg = null
@ -436,10 +367,8 @@ class Stream extends EventEmitter {
Logger.error('Failed to delete session data', err)
})
if (this.socket) {
if (errorMessage) this.socket.emit('stream_error', { id: this.id, error: (errorMessage || '').trim() })
else this.socket.emit('stream_closed', this.id)
}
if (errorMessage) this.clientEmit('stream_error', { id: this.id, error: (errorMessage || '').trim() })
else this.clientEmit('stream_closed', this.id)
this.emit('closed')
}
@ -474,9 +403,19 @@ class Stream extends EventEmitter {
this.isTranscodeComplete = false
this.startTime = time
this.clientCurrentTime = this.startTime
// this.clientCurrentTime = this.startTime
Logger.info(`Stream Reset New Start Time ${secondsToTimestamp(this.startTime)}`)
this.start()
}
clientEmit(evtName, data) {
if (this.clientEmitter) this.clientEmitter(this.user.id, evtName, data)
}
getAudioTrack() {
var newAudioTrack = new AudioTrack()
newAudioTrack.setFromStream(this.itemTitle, this.totalDuration, this.clientPlaylistUri)
return newAudioTrack
}
}
module.exports = Stream

View file

@ -1,6 +1,7 @@
const Path = require('path')
const AudioFile = require('../files/AudioFile')
const { areEquivalent, copyValue } = require('../../utils/index')
const AudioTrack = require('../files/AudioTrack')
class Audiobook {
constructor(audiobook) {
@ -74,6 +75,7 @@ class Audiobook {
}
}
get isPlaybackMediaEntity() { return true }
get tracks() {
return this.audioFiles.filter(af => !af.exclude && !af.invalid)
}
@ -214,5 +216,25 @@ class Audiobook {
removeFileWithInode(inode) {
this.audioFiles = this.audioFiles.filter(af => af.ino !== inode)
}
// Only checks container format
checkCanDirectPlay(payload) {
var supportedMimeTypes = payload.supportedMimeTypes || []
return !this.tracks.some((t) => !supportedMimeTypes.includes(t.mimeType))
}
getDirectPlayTracklist(libraryItemId) {
var tracklist = []
var startOffset = 0
this.tracks.forEach((audioFile) => {
var audioTrack = new AudioTrack()
audioTrack.setData(libraryItemId, audioFile, startOffset)
startOffset += audioTrack.duration
tracklist.push(audioTrack)
})
return tracklist
}
}
module.exports = Audiobook

View file

@ -47,7 +47,7 @@ class EBook {
}
}
toJSONMinified() {
toJSONExpanded() {
return {
id: this.id,
index: this.index,
@ -59,6 +59,7 @@ class EBook {
}
}
get isPlaybackMediaEntity() { return false }
get size() {
return this.ebookFile.metadata.size
}

View file

@ -1,4 +1,5 @@
const AudioFile = require('../files/AudioFile')
const AudioTrack = require('../files/AudioTrack')
class PodcastEpisode {
constructor(episode) {
@ -37,5 +38,22 @@ class PodcastEpisode {
updatedAt: this.updatedAt
}
}
get isPlaybackMediaEntity() { return true }
get tracks() {
return [this.audioFile]
}
// Only checks container format
checkCanDirectPlay(payload) {
var supportedMimeTypes = payload.supportedMimeTypes || []
return supportedMimeTypes.includes(this.audioFile.mimeType)
}
getDirectPlayTracklist(libraryItemId) {
var audioTrack = new AudioTrack()
audioTrack.setData(libraryItemId, this.audioFile, 0)
return [audioTrack]
}
}
module.exports = PodcastEpisode

View file

@ -64,7 +64,8 @@ class AudioFile {
channelLayout: this.channelLayout,
chapters: this.chapters,
embeddedCoverArt: this.embeddedCoverArt,
metaTags: this.metaTags ? this.metaTags.toJSON() : {}
metaTags: this.metaTags ? this.metaTags.toJSON() : {},
mimeType: this.mimeType
}
}
@ -72,9 +73,6 @@ class AudioFile {
this.index = data.index
this.ino = data.ino
this.metadata = new FileMetadata(data.metadata || {})
if (!this.metadata.toJSON) {
console.error('No metadata tojosnm\n\n\n\n\n\n', this)
}
this.addedAt = data.addedAt
this.updatedAt = data.updatedAt
this.manuallyVerified = !!data.manuallyVerified
@ -103,6 +101,22 @@ class AudioFile {
this.metaTags = new AudioMetaTags(data.metaTags || {})
}
get mimeType() {
var ext = this.metadata.ext
if (ext === '.mp3' || ext === '.m4b' || ext === '.m4a') {
return 'audio/mpeg'
} else if (ext === '.mp4') {
return 'audio/mp4'
} else if (ext === '.ogg') {
return 'audio/ogg'
} else if (ext === '.aac' || ext === '.m4p') {
return 'audio/aac'
} else if (ext === '.flac') {
return 'audio/flac'
}
return 'audio/mpeg'
}
// New scanner creates AudioFile from AudioFileScanner
setDataFromProbe(libraryFile, probeData) {
this.ino = libraryFile.ino || null

View file

@ -0,0 +1,42 @@
const Path = require('path')
class AudioTrack {
constructor() {
this.index = null
this.startOffset = null
this.duration = null
this.title = null
this.contentUrl = null
this.mimeType = null
}
toJSON() {
return {
index: this.index,
startOffset: this.startOffset,
duration: this.duration,
title: this.title,
contentUrl: this.contentUrl,
mimeType: this.mimeType
}
}
setData(itemId, audioFile, startOffset) {
this.index = audioFile.index
this.startOffset = startOffset
this.duration = audioFile.duration
this.title = audioFile.metadata.filename || ''
this.contentUrl = Path.join(`/s/item/${itemId}`, audioFile.metadata.relPath)
this.mimeType = audioFile.mimeType
}
setFromStream(title, duration, contentUrl) {
this.index = 1
this.startOffset = 0
this.duration = duration
this.title = title
this.contentUrl = contentUrl
this.mimeType = 'application/vnd.apple.mpegurl'
}
}
module.exports = AudioTrack

View file

@ -108,8 +108,6 @@ class StreamManager {
var stream = await this.openStream(client, libraryItem)
this.db.updateUserStream(client.user.id, stream.id)
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
async closeStreamRequest(socket) {
@ -125,8 +123,6 @@ class StreamManager {
client.user.stream = null
client.stream = null
this.db.updateUserStream(client.user.id, null)
this.emitter('user_stream_update', client.user.toJSONForPublic(this.streams))
}
async closeStreamApiRequest(userId, streamId) {

View file

@ -119,6 +119,15 @@ class Book {
getAudiobookById(audiobookId) {
return this.audiobooks.find(ab => ab.id === audiobookId)
}
getMediaEntityById(entityId) {
var ent = this.audiobooks.find(ab => ab.id === entityId)
if (ent) return ent
return this.ebooks.find(eb => eb.id === entityId)
}
getPlaybackMediaEntity() { // Get first playback media entity
if (!this.audiobooks.length) return null
return this.audiobooks[0]
}
removeFileWithInode(inode) {
var audiobookWithIno = this.audiobooks.find(ab => ab.findFileWithInode(inode))
@ -262,9 +271,5 @@ class Book {
// newEbook.setData(libraryFile)
// this.ebookFiles.push(newEbook)
}
getDirectPlayTracklist(options) {
}
}
module.exports = Book

View file

@ -117,6 +117,14 @@ class Podcast {
return null
}
getMediaEntityById(entityId) {
return this.episodes.find(ep => ep.id === entityId)
}
getPlaybackMediaEntity() { // Get first playback media entity
if (!this.episodes.length) return null
return this.episodes[0]
}
setData(scanMediaMetadata) {
this.metadata = new PodcastMetadata()
this.metadata.setData(scanMediaMetadata)
@ -130,9 +138,5 @@ class Podcast {
var payload = this.metadata.searchQuery(query)
return payload || {}
}
getDirectPlayTracklist(options) {
}
}
module.exports = Podcast

View file

@ -37,7 +37,7 @@ class BookMetadata {
this.isbn = metadata.isbn
this.asin = metadata.asin
this.language = metadata.language
this.explicit = metadata.explicit
this.explicit = !!metadata.explicit
}
toJSON() {
@ -150,6 +150,7 @@ class BookMetadata {
this.asin = scanMediaData.asin || null
this.language = scanMediaData.language || null
this.genres = []
this.explicit = !!scanMediaData.explicit
if (scanMediaData.author) {
this.authors = this.parseAuthorsTag(scanMediaData.author)

View file

@ -47,7 +47,7 @@ class FileMetadata {
get format() {
if (!this.ext) return ''
return this.ext.slice(1)
return this.ext.slice(1).toLowerCase()
}
get filenameNoExt() {
return this.filename.replace(this.ext, '')

View file

@ -42,27 +42,8 @@ class LibraryItemProgress {
this.finishedAt = progress.finishedAt || null
}
updateProgressFromStream(stream) {
// this.audiobookId = stream.libraryItemId
// this.totalDuration = stream.totalDuration
// this.progress = stream.clientProgress
// this.currentTime = stream.clientCurrentTime
// this.lastUpdate = Date.now()
// if (!this.startedAt) {
// this.startedAt = Date.now()
// }
// // If has < 10 seconds remaining mark as read
// var timeRemaining = this.totalDuration - this.currentTime
// if (timeRemaining < 10) {
// this.isFinished = true
// this.progress = 1
// this.finishedAt = Date.now()
// } else {
// this.isFinished = false
// this.finishedAt = null
// }
get inProgress() {
return !this.isFinished && this.progress > 0
}
setData(libraryItemId, progress) {

View file

@ -8,7 +8,6 @@ class User {
this.username = null
this.pash = null
this.type = null
this.stream = null
this.token = null
this.isActive = true
this.isLocked = false
@ -79,7 +78,6 @@ class User {
username: this.username,
pash: this.pash,
type: this.type,
stream: this.stream,
token: this.token,
libraryItemProgress: this.libraryItemProgress ? this.libraryItemProgress.map(li => li.toJSON()) : [],
bookmarks: this.bookmarks ? this.bookmarks.map(b => b.toJSON()) : [],
@ -98,7 +96,6 @@ class User {
id: this.id,
username: this.username,
type: this.type,
stream: this.stream,
token: this.token,
libraryItemProgress: this.libraryItemProgress ? this.libraryItemProgress.map(li => li.toJSON()) : [],
isActive: this.isActive,
@ -112,13 +109,14 @@ class User {
}
// Data broadcasted
toJSONForPublic(streams) {
var stream = this.stream && streams ? streams.find(s => s.id === this.stream) : null
toJSONForPublic(sessions, libraryItems) {
var session = sessions ? sessions.find(s => s.userId === this.id) : null
return {
id: this.id,
username: this.username,
type: this.type,
stream: stream ? stream.toJSON() : null,
session: session ? session.toJSONForClient() : null,
mostRecent: this.getMostRecentItemProgress(libraryItems),
lastSeen: this.lastSeen,
createdAt: this.createdAt
}
@ -129,12 +127,11 @@ class User {
this.username = user.username
this.pash = user.pash
this.type = user.type
this.stream = user.stream || null
this.token = user.token
this.libraryItemProgress = []
if (user.libraryItemProgress) {
this.libraryItemProgress = user.libraryItemProgress.map(li => new LibraryItemProgress(li))
this.libraryItemProgress = user.libraryItemProgress.map(li => new LibraryItemProgress(li)).filter(lip => lip.id)
}
this.bookmarks = []
@ -195,13 +192,22 @@ class User {
return hasUpdates
}
updateAudiobookProgressFromStream(stream) {
// if (!this.audiobooks) this.audiobooks = {}
// if (!this.audiobooks[stream.audiobookId]) {
// this.audiobooks[stream.audiobookId] = new UserAudiobookData()
// }
// this.audiobooks[stream.audiobookId].updateProgressFromStream(stream)
// return this.audiobooks[stream.audiobookId]
getMostRecentItemProgress(libraryItems) {
if (!this.libraryItemProgress.length) return null
var lip = this.libraryItemProgress.map(lip => lip.toJSON())
lip.sort((a, b) => b.lastUpdate - a.lastUpdate)
var mostRecentWithLip = lip.find(li => libraryItems.find(_li => _li.id === li.id))
if (!mostRecentWithLip) return null
var libraryItem = libraryItems.find(li => li.id === mostRecentWithLip.id)
return {
...mostRecentWithLip,
media: libraryItem.media.toJSONExpanded()
}
}
getLibraryItemProgress(libraryItemId) {
if (!this.libraryItemProgress) return null
return this.libraryItemProgress.find(lip => lip.id === libraryItemId)
}
createUpdateLibraryItemProgress(libraryItemId, updatePayload) {
@ -254,12 +260,6 @@ class User {
return this.librariesAccessible.includes(libraryId)
}
getLibraryItemProgress(libraryItemId) {
if (!this.libraryItemProgress) return null
var progress = this.libraryItemProgress.find(lip => lip.id === libraryItemId)
return progress ? progress.toJSON() : null
}
createBookmark({ libraryItemId, time, title }) {
// if (!this.audiobooks) this.audiobooks = {}
// if (!this.audiobooks[audiobookId]) {

View file

@ -4,29 +4,30 @@ const fs = require('fs-extra')
const date = require('date-and-time')
const axios = require('axios')
const Logger = require('./Logger')
const { isObject } = require('./utils/index')
const { parsePodcastRssFeedXml } = require('./utils/podcastUtils')
const Logger = require('../Logger')
const { isObject } = require('../utils/index')
const { parsePodcastRssFeedXml } = require('../utils/podcastUtils')
const LibraryController = require('./controllers/LibraryController')
const UserController = require('./controllers/UserController')
const CollectionController = require('./controllers/CollectionController')
const MeController = require('./controllers/MeController')
const BackupController = require('./controllers/BackupController')
const LibraryItemController = require('./controllers/LibraryItemController')
const SeriesController = require('./controllers/SeriesController')
const AuthorController = require('./controllers/AuthorController')
const AudiobookController = require('./controllers/AudiobookController')
const LibraryController = require('../controllers/LibraryController')
const UserController = require('../controllers/UserController')
const CollectionController = require('../controllers/CollectionController')
const MeController = require('../controllers/MeController')
const BackupController = require('../controllers/BackupController')
const LibraryItemController = require('../controllers/LibraryItemController')
const SeriesController = require('../controllers/SeriesController')
const AuthorController = require('../controllers/AuthorController')
const MediaEntityController = require('../controllers/MediaEntityController')
const SessionController = require('../controllers/SessionController')
const BookFinder = require('./finders/BookFinder')
const AuthorFinder = require('./finders/AuthorFinder')
const PodcastFinder = require('./finders/PodcastFinder')
const BookFinder = require('../finders/BookFinder')
const AuthorFinder = require('../finders/AuthorFinder')
const PodcastFinder = require('../finders/PodcastFinder')
const Author = require('./objects/entities/Author')
const Series = require('./objects/entities/Series')
const FileSystemController = require('./controllers/FileSystemController')
const Author = require('../objects/entities/Author')
const Series = require('../objects/entities/Series')
const FileSystemController = require('../controllers/FileSystemController')
class ApiController {
class ApiRouter {
constructor(db, auth, scanner, playbackSessionManager, downloadManager, coverController, backupManager, watcher, cacheManager, emitter, clientEmitter) {
this.db = db
this.auth = auth
@ -72,11 +73,12 @@ class ApiController {
this.router.post('/libraries/order', LibraryController.reorder.bind(this))
//
// Audiobook Routes
// Media Entity Routes
//
this.router.get('/audiobooks/:id', AudiobookController.middleware.bind(this), AudiobookController.findOne.bind(this))
this.router.get('/audiobooks/:id/item', AudiobookController.middleware.bind(this), AudiobookController.findWithItem.bind(this))
this.router.patch('/audiobooks/:id/tracks', AudiobookController.middleware.bind(this), AudiobookController.updateTracks.bind(this))
this.router.get('/entities/:id', MediaEntityController.middleware.bind(this), MediaEntityController.findOne.bind(this))
this.router.get('/entities/:id/item', MediaEntityController.middleware.bind(this), MediaEntityController.findWithItem.bind(this))
this.router.patch('/entities/:id/tracks', MediaEntityController.middleware.bind(this), MediaEntityController.updateTracks.bind(this))
this.router.post('/entities/:id/play', MediaEntityController.middleware.bind(this), MediaEntityController.startPlaybackSession.bind(this))
//
// Item Routes
@ -92,13 +94,11 @@ class ApiController {
this.router.patch('/items/:id/cover', LibraryItemController.middleware.bind(this), LibraryItemController.updateCover.bind(this))
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.get('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
this.router.post('/items/:id/play', LibraryItemController.middleware.bind(this), LibraryItemController.startPlaybackSession.bind(this))
this.router.post('/items/batch/delete', LibraryItemController.batchDelete.bind(this))
this.router.post('/items/batch/update', LibraryItemController.batchUpdate.bind(this))
this.router.post('/items/batch/get', LibraryItemController.batchGet.bind(this))
// Legacy
this.router.get('/items/:id/stream', LibraryItemController.middleware.bind(this), LibraryItemController.openStream.bind(this))
//
// User Routes
@ -171,6 +171,12 @@ class ApiController {
this.router.get('/series/search', SeriesController.search.bind(this))
this.router.get('/series/:id', SeriesController.middleware.bind(this), SeriesController.findOne.bind(this))
//
// Playback Session Routes
//
this.router.post('/session/:id/sync', SessionController.middleware.bind(this), SessionController.sync.bind(this))
this.router.post('/session/:id/close', SessionController.middleware.bind(this), SessionController.close.bind(this))
//
// Misc Routes
//
@ -180,14 +186,10 @@ class ApiController {
this.router.get('/download/:id', this.download.bind(this))
this.router.post('/syncUserAudiobookData', this.syncUserAudiobookData.bind(this))
this.router.post('/purgecache', this.purgeCache.bind(this))
this.router.post('/syncStream', this.syncStream.bind(this))
this.router.post('/syncLocal', this.syncLocal.bind(this))
this.router.post('/streams/:id/close', this.closeStream.bind(this))
// OLD
// this.router.post('/syncUserAudiobookData', this.syncUserAudiobookData.bind(this))
this.router.post('/getPodcastFeed', this.getPodcastFeed.bind(this))
}
@ -337,45 +339,21 @@ class ApiController {
// res.json(allUserAudiobookData)
}
// Sync audiobook stream progress
async syncStream(req, res) {
Logger.debug(`[ApiController] syncStream for ${req.user.username} - ${req.body.streamId}`)
// this.streamManager.streamSyncFromApi(req, res)
res.sendStatus(500)
}
// Sync local downloaded audiobook progress
async syncLocal(req, res) {
// Logger.debug(`[ApiController] syncLocal for ${req.user.username}`)
// var progressPayload = req.body
// var itemProgress = req.user.updateLibraryItemProgress(progressPayload.libraryItemId, progressPayload)
// if (itemProgress) {
// await this.db.updateEntity('user', req.user)
// this.clientEmitter(req.user.id, 'current_user_audiobook_update', {
// id: progressPayload.libraryItemId,
// data: itemProgress || null
// })
// }
res.sendStatus(200)
}
//
// Helper Methods
//
userJsonWithBookProgressDetails(user) {
userJsonWithItemProgressDetails(user) {
var json = user.toJSONForBrowser()
// User audiobook progress attach book details
if (json.audiobooks && Object.keys(json.audiobooks).length) {
for (const audiobookId in json.audiobooks) {
var libraryItem = this.db.libraryItems.find(li => li.id === audiobookId)
if (!libraryItem) {
Logger.error('[ApiController] Library item not found for users progress ' + audiobookId)
} else {
json.audiobooks[audiobookId].media = libraryItem.media.toJSONExpanded()
}
json.libraryItemProgress = json.libraryItemProgress.map(lip => {
var libraryItem = this.db.libraryItems.find(li => li.id === lip.id)
if (!libraryItem) {
Logger.warn('[ApiRouter] Library item not found for users progress ' + lip.id)
return null
}
}
lip.media = libraryItem.media.toJSONExpanded()
return lip
}).filter(lip => !!lip)
return json
}
@ -425,8 +403,7 @@ class ApiController {
async getUserListeningSessionsHelper(userId) {
var userSessions = await this.db.selectUserSessions(userId)
var listeningSessions = userSessions.filter(us => us.sessionType === 'listeningSession')
return listeningSessions.sort((a, b) => b.lastUpdate - a.lastUpdate)
return userSessions.sort((a, b) => b.updatedAt - a.updatedAt)
}
async getUserListeningStatsHelpers(userId) {
@ -435,7 +412,7 @@ class ApiController {
var listeningSessions = await this.getUserListeningSessionsHelper(userId)
var listeningStats = {
totalTime: 0,
books: {},
items: {},
days: {},
dayOfWeek: {},
today: 0,
@ -454,16 +431,15 @@ class ApiController {
listeningStats.today += s.timeListening
}
}
if (!listeningStats.books[s.audiobookId]) {
listeningStats.books[s.audiobookId] = {
id: s.audiobookId,
if (!listeningStats.items[s.libraryItemId]) {
listeningStats.items[s.libraryItemId] = {
id: s.libraryItemId,
timeListening: s.timeListening,
title: s.audiobookTitle,
author: s.audiobookAuthor,
mediaMetadata: s.mediaMetadata,
lastUpdate: s.lastUpdate
}
} else {
listeningStats.books[s.audiobookId].timeListening += s.timeListening
listeningStats.items[s.libraryItemId].timeListening += s.timeListening
}
listeningStats.totalTime += s.timeListening
@ -475,18 +451,11 @@ class ApiController {
if (!req.user.isRoot) {
return res.sendStatus(403)
}
Logger.info(`[ApiController] Purging all cache`)
Logger.info(`[ApiRouter] Purging all cache`)
await this.cacheManager.purgeAll()
res.sendStatus(200)
}
async closeStream(req, res) {
const streamId = req.params.id
const userId = req.user.id
// this.streamManager.closeStreamApiRequest(userId, streamId)
res.sendStatus(200)
}
async createAuthorsAndSeriesForItemUpdate(mediaPayload) {
if (mediaPayload.metadata) {
var mediaMetadata = mediaPayload.metadata
@ -501,7 +470,7 @@ class ApiController {
if (!author) {
author = new Author()
author.setData(mediaMetadata.authors[i])
Logger.debug(`[ApiController] Created new author "${author.name}"`)
Logger.debug(`[ApiRouter] Created new author "${author.name}"`)
newAuthors.push(author)
}
@ -525,7 +494,7 @@ class ApiController {
if (!seriesItem) {
seriesItem = new Series()
seriesItem.setData(mediaMetadata.series[i])
Logger.debug(`[ApiController] Created new series "${seriesItem.name}"`)
Logger.debug(`[ApiRouter] Created new series "${seriesItem.name}"`)
newSeries.push(seriesItem)
}
@ -563,4 +532,4 @@ class ApiController {
})
}
}
module.exports = ApiController
module.exports = ApiRouter

View file

@ -1,13 +1,13 @@
const express = require('express')
const Path = require('path')
const fs = require('fs-extra')
const Logger = require('./Logger')
const Logger = require('../Logger')
class HlsController {
class HlsRouter {
constructor(db, auth, playbackSessionManager, emitter) {
this.db = db
this.auth = auth
this.streamManager = playbackSessionManager
this.playbackSessionManager = playbackSessionManager
this.emitter = emitter
this.router = express()
@ -26,13 +26,7 @@ class HlsController {
async streamFileRequest(req, res) {
var streamId = req.params.stream
var fullFilePath = Path.join(this.streamManager.StreamsPath, streamId, req.params.file)
// development test stream - ignore
if (streamId === 'test') {
Logger.debug('Test Stream Request', streamId, req.headers, fullFilePath)
return res.sendFile(fullFilePath)
}
var fullFilePath = Path.join(this.playbackSessionManager.StreamsPath, streamId, req.params.file)
var exists = await fs.pathExists(fullFilePath)
if (!exists) {
@ -41,20 +35,20 @@ class HlsController {
var fileExt = Path.extname(req.params.file)
if (fileExt === '.ts' || fileExt === '.m4s') {
var segNum = this.parseSegmentFilename(req.params.file)
var stream = this.streamManager.getStream(streamId)
var stream = this.playbackSessionManager.getStream(streamId)
if (!stream) {
Logger.error(`[HLS-CONTROLLER] Stream ${streamId} does not exist`)
Logger.error(`[HlsRouter] Stream ${streamId} does not exist`)
return res.sendStatus(500)
}
if (stream.isResetting) {
Logger.info(`[HLS-CONTROLLER] Stream ${streamId} is currently resetting`)
Logger.info(`[HlsRouter] Stream ${streamId} is currently resetting`)
return res.sendStatus(404)
} else {
var startTimeForReset = await stream.checkSegmentNumberRequest(segNum)
if (startTimeForReset) {
// HLS.js will restart the stream at the new time
Logger.info(`[HLS-CONTROLLER] Resetting Stream - notify client @${startTimeForReset}s`)
Logger.info(`[HlsRouter] Resetting Stream - notify client @${startTimeForReset}s`)
this.emitter('stream_reset', {
startTime: startTimeForReset,
streamId: stream.id
@ -69,4 +63,4 @@ class HlsController {
res.sendFile(fullFilePath)
}
}
module.exports = HlsController
module.exports = HlsRouter

View file

@ -0,0 +1,25 @@
const express = require('express')
const Path = require('path')
const Logger = require('../Logger')
class StaticRouter {
constructor(db) {
this.db = db
this.router = express()
this.init()
}
init() {
// Library Item static file routes
this.router.get('/item/:id/*', (req, res) => {
var item = this.db.libraryItems.find(ab => ab.id === req.params.id)
if (!item) return res.status(404).send('Item not found with id ' + req.params.id)
var remainingPath = req.params['0']
var fullPath = Path.join(item.path, remainingPath)
res.sendFile(fullPath)
})
}
}
module.exports = StaticRouter

View file

@ -33,20 +33,6 @@ class Scanner {
this.bookFinder = new BookFinder()
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.storeCoverWithBook) {
return {
fullPath: audiobook.fullPath,
relPath: '/s/book/' + audiobook.id
}
} else {
return {
fullPath: Path.posix.join(this.BookMetadataPath, audiobook.id),
relPath: Path.posix.join('/metadata', 'books', audiobook.id)
}
}
}
isLibraryScanning(libraryId) {
return this.librariesScanning.find(ls => ls.id === libraryId)
}

View file

@ -3,6 +3,7 @@ const fs = require('fs-extra')
const njodb = require("njodb")
const { SupportedEbookTypes } = require('./globals')
const { PlayMethod } = require('./constants')
const { getId } = require('./index')
const Logger = require('../Logger')
@ -335,6 +336,8 @@ function cleanSessionObj(db, userListeningSession) {
newPlaybackSession.mediaType = 'book'
newPlaybackSession.updatedAt = userListeningSession.lastUpdate
newPlaybackSession.libraryItemId = userListeningSession.audiobookId
newPlaybackSession.mediaEntityId = userListeningSession.audiobookId
newPlaybackSession.playMethod = PlayMethod.TRANSCODE
// We only have title to transfer over nicely
var bookMetadata = new BookMetadata()

View file

@ -132,9 +132,6 @@ async function recurseFiles(path, relPathToReplace = null) {
// Sort from least deep to most
list.sort((a, b) => a.deep - b.deep)
// list.forEach((l) => {
// console.log(`${l.deep}: ${l.path}`)
// })
return list
}
module.exports.recurseFiles = recurseFiles

View file

@ -28,11 +28,10 @@ module.exports = {
else if (group === 'narrators') filtered = filtered.filter(li => li.media.metadata && li.media.metadata.hasNarrator(filter))
else if (group === 'progress') {
filtered = filtered.filter(li => {
var userAudiobook = user.getLibraryItemProgress(li.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
var itemProgress = user.getLibraryItemProgress(li.id)
if (filter === 'Finished' && (itemProgress && itemProgress.isFinished)) return true
if (filter === 'Not Started' && !itemProgress) return true
if (filter === 'In Progress' && (itemProgress && itemProgress.inProgress)) return true
return false
})
} else if (group === 'languages') {
@ -49,43 +48,6 @@ module.exports = {
return filtered
},
getFiltered(audiobooks, filterBy, user) {
var filtered = audiobooks
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators', 'languages']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filterVal = filterBy.replace(`${group}.`, '')
var filter = this.decode(filterVal)
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
else if (group === 'series') {
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
}
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter))
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter))
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = user.getLibraryItemProgress(ab.id)
var isRead = userAudiobook && userAudiobook.isRead
if (filter === 'Read' && isRead) return true
if (filter === 'Unread' && !isRead) return true
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
return false
})
} else if (group === 'languages') {
filtered = filtered.filter(ab => ab.book && ab.book.language === filter)
}
} else if (filterBy === 'issues') {
filtered = filtered.filter(ab => {
return ab.numMissingParts || ab.numInvalidParts || ab.isMissing || ab.isInvalid
})
}
return filtered
},
getDistinctFilterDataNew(libraryItems) {
var data = {
authors: [],
@ -160,26 +122,27 @@ module.exports = {
},
getSeriesWithProgressFromBooks(user, books) {
var _series = {}
books.forEach((audiobook) => {
if (audiobook.book.series) {
var bookWithUserAb = { userAudiobook: user.getLibraryItemProgress(audiobook.id), book: audiobook }
if (!_series[audiobook.book.series]) {
_series[audiobook.book.series] = {
id: audiobook.book.series,
name: audiobook.book.series,
type: 'series',
books: [bookWithUserAb]
}
} else {
_series[audiobook.book.series].books.push(bookWithUserAb)
}
}
})
return Object.values(_series).map((series) => {
series.books = naturalSort(series.books).asc(ab => ab.book.book.volumeNumber)
return series
}).filter((series) => series.books.some((book) => book.userAudiobook && book.userAudiobook.isRead))
return []
// var _series = {}
// books.forEach((audiobook) => {
// if (audiobook.book.series) {
// var bookWithUserAb = { userAudiobook: user.getLibraryItemProgress(audiobook.id), book: audiobook }
// if (!_series[audiobook.book.series]) {
// _series[audiobook.book.series] = {
// id: audiobook.book.series,
// name: audiobook.book.series,
// type: 'series',
// books: [bookWithUserAb]
// }
// } else {
// _series[audiobook.book.series].books.push(bookWithUserAb)
// }
// }
// })
// return Object.values(_series).map((series) => {
// series.books = naturalSort(series.books).asc(ab => ab.book.book.volumeNumber)
// return series
// }).filter((series) => series.books.some((book) => book.userAudiobook && book.userAudiobook.isRead))
},
sortSeriesBooks(books, seriesId, minified = false) {
@ -196,8 +159,9 @@ module.exports = {
getItemsWithUserProgress(user, libraryItems) {
return libraryItems.map(li => {
var itemProgress = user.getLibraryItemProgress(li.id)
return {
userProgress: user.getLibraryItemProgress(li.id),
userProgress: itemProgress ? itemProgress.toJSON() : null,
libraryItem: li
}
}).filter(b => !!b.userProgress)