466 lines
15 KiB
JavaScript
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()
|