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