audiobookshelf/server/controllers/MeController.js

466 lines
15 KiB
JavaScript

const { Request, Response } = require('express')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
const { toNumber, isNullOrNaN } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
/**
* @typedef RequestUserObject
* @property {import('../models/User')} user
*
* @typedef {Request & RequestUserObject} RequestWithUser
*/
class MeController {
constructor() {}
/**
* GET: /api/me
*
* @param {RequestWithUser} req
* @param {Response} res
*/
getCurrentUser(req, res) {
res.json(req.user.toOldJSONForBrowser())
}
/**
* GET: /api/me/listening-sessions
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getListeningSessions(req, res) {
const listeningSessions = await this.getUserListeningSessionsHelper(req.user.id)
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
res.json(payload)
}
/**
* GET: /api/me/item/listening-sessions/:libraryItemId/:episodeId
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getItemListeningSessions(req, res) {
const libraryItem = await Database.libraryItemModel.findByPk(req.params.libraryItemId)
const episode = await Database.podcastEpisodeModel.findByPk(req.params.episodeId)
if (!libraryItem || (libraryItem.mediaType === 'podcast' && !episode)) {
Logger.error(`[MeController] Media item not found for library item id "${req.params.libraryItemId}"`)
return res.sendStatus(404)
}
const mediaItemId = episode?.id || libraryItem.mediaId
let listeningSessions = await this.getUserItemListeningSessionsHelper(req.user.id, mediaItemId)
const itemsPerPage = toNumber(req.query.itemsPerPage, 10) || 10
const page = toNumber(req.query.page, 0)
const start = page * itemsPerPage
const sessions = listeningSessions.slice(start, start + itemsPerPage)
const payload = {
total: listeningSessions.length,
numPages: Math.ceil(listeningSessions.length / itemsPerPage),
page,
itemsPerPage,
sessions
}
res.json(payload)
}
/**
* GET: /api/me/listening-stats
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getListeningStats(req, res) {
const listeningStats = await this.getUserListeningStatsHelpers(req.user.id)
res.json(listeningStats)
}
/**
* GET: /api/me/progress/:id/:episodeId?
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getMediaProgress(req, res) {
const mediaProgress = req.user.getOldMediaProgress(req.params.id, req.params.episodeId || null)
if (!mediaProgress) {
return res.sendStatus(404)
}
res.json(mediaProgress)
}
/**
* DELETE: /api/me/progress/:id
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeMediaProgress(req, res) {
await Database.mediaProgressModel.removeById(req.params.id)
req.user.mediaProgresses = req.user.mediaProgresses.filter((mp) => mp.id !== req.params.id)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* PATCH: /api/me/progress/:libraryItemId/:episodeId?
* TODO: Update to use mediaItemId and mediaItemType
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async createUpdateMediaProgress(req, res) {
const progressUpdatePayload = {
...req.body,
libraryItemId: req.params.libraryItemId,
episodeId: req.params.episodeId
}
const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(progressUpdatePayload)
if (mediaProgressResponse.error) {
return res.status(mediaProgressResponse.statusCode || 400).send(mediaProgressResponse.error)
}
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* PATCH: /api/me/progress/batch/update
* TODO: Update to use mediaItemId and mediaItemType
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async batchUpdateMediaProgress(req, res) {
const itemProgressPayloads = req.body
if (!itemProgressPayloads?.length) {
return res.status(400).send('Missing request payload')
}
let hasUpdated = false
for (const itemProgress of itemProgressPayloads) {
const mediaProgressResponse = await req.user.createUpdateMediaProgressFromPayload(itemProgress)
if (mediaProgressResponse.error) {
Logger.error(`[MeController] batchUpdateMediaProgress: ${mediaProgressResponse.error}`)
continue
} else {
hasUpdated = true
}
}
if (hasUpdated) {
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
res.sendStatus(200)
}
/**
* POST: /api/me/item/:id/bookmark
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async createBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
if (isNullOrNaN(time)) {
Logger.error(`[MeController] createBookmark invalid time`, time)
return res.status(400).send('Invalid time')
}
if (!title || typeof title !== 'string') {
Logger.error(`[MeController] createBookmark invalid title`, title)
return res.status(400).send('Invalid title')
}
const bookmark = await req.user.createBookmark(req.params.id, time, title)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.json(bookmark)
}
/**
* PATCH: /api/me/item/:id/bookmark
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const { time, title } = req.body
if (isNullOrNaN(time)) {
Logger.error(`[MeController] updateBookmark invalid time`, time)
return res.status(400).send('Invalid time')
}
if (!title || typeof title !== 'string') {
Logger.error(`[MeController] updateBookmark invalid title`, title)
return res.status(400).send('Invalid title')
}
const bookmark = await req.user.updateBookmark(req.params.id, time, title)
if (!bookmark) {
Logger.error(`[MeController] updateBookmark not found for library item id "${req.params.id}" and time "${time}"`)
return res.sendStatus(404)
}
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.json(bookmark)
}
/**
* DELETE: /api/me/item/:id/bookmark/:time
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeBookmark(req, res) {
if (!(await Database.libraryItemModel.checkExistsById(req.params.id))) return res.sendStatus(404)
const time = Number(req.params.time)
if (isNaN(time)) {
return res.status(400).send('Invalid time')
}
if (!req.user.findBookmark(req.params.id, time)) {
Logger.error(`[MeController] removeBookmark not found`)
return res.sendStatus(404)
}
await req.user.removeBookmark(req.params.id, time)
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.sendStatus(200)
}
/**
* PATCH: /api/me/password
* User change password. Requires current password.
* Guest users cannot change password.
*
* @this import('../routers/ApiRouter')
*
* @param {RequestWithUser} req
* @param {Response} res
*/
updatePassword(req, res) {
if (req.user.isGuest) {
Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
return res.sendStatus(500)
}
this.auth.userChangePassword(req, res)
}
/**
* GET: /api/me/items-in-progress
* Pull items in progress for all libraries
* Used in Android Auto in progress list since there is no easy library selection
* TODO: Update to use mediaItemId and mediaItemType. Use sort & limit in query
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getAllLibraryItemsInProgress(req, res) {
const limit = !isNaN(req.query.limit) ? Number(req.query.limit) || 25 : 25
const mediaProgressesInProgress = req.user.mediaProgresses.filter((mp) => !mp.isFinished && (mp.currentTime > 0 || mp.ebookProgress > 0))
const libraryItemsIds = [...new Set(mediaProgressesInProgress.map((mp) => mp.extraData?.libraryItemId).filter((id) => id))]
const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({ id: libraryItemsIds })
let itemsInProgress = []
for (const mediaProgress of mediaProgressesInProgress) {
const oldMediaProgress = mediaProgress.getOldMediaProgress()
const libraryItem = libraryItems.find((li) => li.id === oldMediaProgress.libraryItemId)
if (libraryItem) {
if (oldMediaProgress.episodeId && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === oldMediaProgress.episodeId)
if (episode) {
const libraryItemWithEpisode = {
...libraryItem.toJSONMinified(),
recentEpisode: episode.toJSON(),
progressLastUpdate: oldMediaProgress.lastUpdate
}
itemsInProgress.push(libraryItemWithEpisode)
}
} else if (!oldMediaProgress.episodeId) {
itemsInProgress.push({
...libraryItem.toJSONMinified(),
progressLastUpdate: oldMediaProgress.lastUpdate
})
}
}
}
itemsInProgress = sort(itemsInProgress)
.desc((li) => li.progressLastUpdate)
.slice(0, limit)
res.json({
libraryItems: itemsInProgress
})
}
/**
* GET: /api/me/series/:id/remove-from-continue-listening
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeSeriesFromContinueListening(req, res) {
if (!(await Database.seriesModel.checkExistsById(req.params.id))) {
Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
}
const hasUpdated = await req.user.addSeriesToHideFromContinueListening(req.params.id)
if (hasUpdated) {
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
res.json(req.user.toOldJSONForBrowser())
}
/**
* GET: api/me/series/:id/readd-to-continue-listening
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async readdSeriesFromContinueListening(req, res) {
if (!(await Database.seriesModel.checkExistsById(req.params.id))) {
Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`)
return res.sendStatus(404)
}
const hasUpdated = await req.user.removeSeriesFromHideFromContinueListening(req.params.id)
if (hasUpdated) {
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
}
res.json(req.user.toOldJSONForBrowser())
}
/**
* GET: api/me/progress/:id/remove-from-continue-listening
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async removeItemFromContinueListening(req, res) {
const mediaProgress = req.user.mediaProgresses.find((mp) => mp.id === req.params.id)
if (!mediaProgress) {
return res.sendStatus(404)
}
// Already hidden
if (mediaProgress.hideFromContinueListening) {
return res.json(req.user.toOldJSONForBrowser())
}
mediaProgress.hideFromContinueListening = true
await mediaProgress.save()
SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toOldJSONForBrowser())
res.json(req.user.toOldJSONForBrowser())
}
/**
* POST: /api/me/ereader-devices
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async updateUserEReaderDevices(req, res) {
if (!req.body.ereaderDevices || !Array.isArray(req.body.ereaderDevices)) {
return res.status(400).send('Invalid payload. ereaderDevices array required')
}
const userEReaderDevices = req.body.ereaderDevices
for (const device of userEReaderDevices) {
if (!device.name || !device.email) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have name and email')
} else if (device.availabilityOption !== 'specificUsers' || device.users?.length !== 1 || device.users[0] !== req.user.id) {
return res.status(400).send('Invalid payload. ereaderDevices array items must have availabilityOption "specificUsers" and only the current user')
}
}
const otherDevices = Database.emailSettings.ereaderDevices.filter((device) => {
return !Database.emailSettings.checkUserCanAccessDevice(device, req.user) || device.users?.length !== 1
})
const ereaderDevices = otherDevices.concat(userEReaderDevices)
// Check for duplicate names
const nameSet = new Set()
const hasDupes = ereaderDevices.some((device) => {
if (nameSet.has(device.name)) {
return true // Duplicate found
}
nameSet.add(device.name)
return false
})
if (hasDupes) {
return res.status(400).send('Invalid payload. Duplicate "name" field found.')
}
const updated = Database.emailSettings.update({ ereaderDevices })
if (updated) {
await Database.updateSetting(Database.emailSettings)
SocketAuthority.clientEmitter(req.user.id, 'ereader-devices-updated', {
ereaderDevices: Database.emailSettings.ereaderDevices
})
}
res.json({
ereaderDevices: Database.emailSettings.getEReaderDevices(req.user)
})
}
/**
* GET: /api/me/stats/year/:year
*
* @param {import('express').Request} req
* @param {import('express').Response} res
*/
async getStatsForYear(req, res) {
const year = Number(req.params.year)
if (isNaN(year) || year < 2000 || year > 9999) {
Logger.error(`[MeController] Invalid year "${year}"`)
return res.status(400).send('Invalid year')
}
const data = await userStats.getStatsForYear(req.user.id, year)
res.json(data)
}
}
module.exports = new MeController()