From 9facf77ff10d604abeccd1a82c6239ba95db9558 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 11 Aug 2024 13:09:53 -0500 Subject: [PATCH] Update remove old sync local sessions endpoint & update MeController routes to use new user model --- server/Auth.js | 21 +-- server/controllers/MeController.js | 210 ++++++++++++----------------- server/models/User.js | 35 +++++ server/objects/user/User.js | 34 ----- server/routers/ApiRouter.js | 1 - server/utils/queries/userStats.js | 41 +++--- 6 files changed, 156 insertions(+), 186 deletions(-) diff --git a/server/Auth.js b/server/Auth.js index 8c0d0991f8..ec229c0a6d 100644 --- a/server/Auth.js +++ b/server/Auth.js @@ -1,5 +1,6 @@ const axios = require('axios') const passport = require('passport') +const { Request, Response, NextFunction } = require('express') const bcrypt = require('./libs/bcryptjs') const jwt = require('./libs/jsonwebtoken') const LocalStrategy = require('./libs/passportLocal') @@ -355,8 +356,8 @@ class Auth { * - 'openid': OpenID authentication directly over web * - 'openid-mobile': OpenID authentication, but done via an mobile device * - * @param {import('express').Request} req - * @param {import('express').Response} res + * @param {Request} req + * @param {Response} res * @param {string} authMethod - The authentication method, default is 'local'. */ paramsToCookies(req, res, authMethod = 'local') { @@ -385,8 +386,8 @@ class Auth { * Informs the client in the right mode about a successfull login and the token * (clients choise is restored from cookies). * - * @param {import('express').Request} req - * @param {import('express').Response} res + * @param {Request} req + * @param {Response} res */ async handleLoginSuccessBasedOnCookie(req, res) { // get userLogin json (information about the user, server and the session) @@ -740,9 +741,9 @@ class Auth { /** * middleware to use in express to only allow authenticated users. - * @param {import('express').Request} req - * @param {import('express').Response} res - * @param {import('express').NextFunction} next + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next */ isAuthenticated(req, res, next) { // check if session cookie says that we are authenticated @@ -914,13 +915,13 @@ class Auth { * User changes their password from request * TODO: Update responses to use error status codes * - * @param {import('express').Request} req - * @param {import('express').Response} res + * @param {import('./controllers/MeController').RequestWithUser} req + * @param {Response} res */ async userChangePassword(req, res) { let { password, newPassword } = req.body newPassword = newPassword || '' - const matchingUser = req.user + const matchingUser = req.userNew // Only root can have an empty password if (matchingUser.type !== 'root' && !newPassword) { diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 6d27883d5e..2699f69728 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -31,6 +31,8 @@ class MeController { /** * GET: /api/me/listening-sessions * + * @this import('../routers/ApiRouter') + * * @param {RequestWithUser} req * @param {Response} res */ @@ -94,6 +96,8 @@ class MeController { /** * GET: /api/me/listening-stats * + * @this import('../routers/ApiRouter') + * * @param {RequestWithUser} req * @param {Response} res */ @@ -261,110 +265,62 @@ class MeController { res.sendStatus(200) } - // PATCH: api/me/password + /** + * 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 attempted to change password`, req.user.username) + if (req.userNew.isGuest) { + Logger.error(`[MeController] Guest user "${req.userNew.username}" attempted to change password`) return res.sendStatus(500) } this.auth.userChangePassword(req, res) } - // TODO: Deprecated. Removed from Android. Only used in iOS app now. - // POST: api/me/sync-local-progress - async syncLocalMediaProgress(req, res) { - if (!req.body.localMediaProgress) { - Logger.error(`[MeController] syncLocalMediaProgress invalid post body`) - return res.sendStatus(500) - } - const updatedLocalMediaProgress = [] - let numServerProgressUpdates = 0 - const updatedServerMediaProgress = [] - const localMediaProgress = req.body.localMediaProgress || [] - - for (const localProgress of localMediaProgress) { - if (!localProgress.libraryItemId) { - Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object`, localProgress) - continue - } - - const libraryItem = await Database.libraryItemModel.getOldById(localProgress.libraryItemId) - if (!libraryItem) { - Logger.error(`[MeController] syncLocalMediaProgress invalid local media progress object no library item with id "${localProgress.libraryItemId}"`, localProgress) - continue - } - - let mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) - if (!mediaProgress) { - // New media progress from mobile - Logger.debug(`[MeController] syncLocalMediaProgress local progress is new - creating ${localProgress.id}`) - req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) - mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) - if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) - updatedServerMediaProgress.push(mediaProgress) - numServerProgressUpdates++ - } else if (mediaProgress.lastUpdate < localProgress.lastUpdate) { - Logger.debug(`[MeController] syncLocalMediaProgress local progress is more recent - updating ${mediaProgress.id}`) - req.user.createUpdateMediaProgress(libraryItem, localProgress, localProgress.episodeId) - mediaProgress = req.user.getMediaProgress(localProgress.libraryItemId, localProgress.episodeId) - if (mediaProgress) await Database.upsertMediaProgress(mediaProgress) - updatedServerMediaProgress.push(mediaProgress) - numServerProgressUpdates++ - } else if (mediaProgress.lastUpdate > localProgress.lastUpdate) { - const updateTimeDifference = mediaProgress.lastUpdate - localProgress.lastUpdate - Logger.debug(`[MeController] syncLocalMediaProgress server progress is more recent by ${updateTimeDifference}ms - ${mediaProgress.id}`) - - for (const key in localProgress) { - // Local media progress ID uses the local library item id and server media progress uses the library item id - if (key !== 'id' && mediaProgress[key] != undefined && localProgress[key] !== mediaProgress[key]) { - // Logger.debug(`[MeController] syncLocalMediaProgress key ${key} changed from ${localProgress[key]} to ${mediaProgress[key]} - ${mediaProgress.id}`) - localProgress[key] = mediaProgress[key] - } - } - updatedLocalMediaProgress.push(localProgress) - } else { - Logger.debug(`[MeController] syncLocalMediaProgress server and local are in sync - ${mediaProgress.id}`) - } - } - - Logger.debug(`[MeController] syncLocalMediaProgress server updates = ${numServerProgressUpdates}, local updates = ${updatedLocalMediaProgress.length}`) - if (numServerProgressUpdates > 0) { - SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) - } - - res.json({ - numServerProgressUpdates, - localProgressUpdates: updatedLocalMediaProgress, // Array of LocalMediaProgress that were updated from server (server more recent) - serverProgressUpdates: updatedServerMediaProgress // Array of MediaProgress that made updates to server (local more recent) - }) - } - - // GET: api/me/items-in-progress + /** + * 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.userNew.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 = [] - // TODO: More efficient to do this in a single query - for (const mediaProgress of req.user.mediaProgress) { - if (!mediaProgress.isFinished && (mediaProgress.progress > 0 || mediaProgress.ebookProgress > 0)) { - const libraryItem = await Database.libraryItemModel.getOldById(mediaProgress.libraryItemId) - if (libraryItem) { - if (mediaProgress.episodeId && libraryItem.mediaType === 'podcast') { - const episode = libraryItem.media.episodes.find((ep) => ep.id === mediaProgress.episodeId) - if (episode) { - const libraryItemWithEpisode = { - ...libraryItem.toJSONMinified(), - recentEpisode: episode.toJSON(), - progressLastUpdate: mediaProgress.lastUpdate - } - itemsInProgress.push(libraryItemWithEpisode) - } - } else if (!mediaProgress.episodeId) { - itemsInProgress.push({ + + 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(), - progressLastUpdate: mediaProgress.lastUpdate - }) + recentEpisode: episode.toJSON(), + progressLastUpdate: oldMediaProgress.lastUpdate + } + itemsInProgress.push(libraryItemWithEpisode) } + } else if (!oldMediaProgress.episodeId) { + itemsInProgress.push({ + ...libraryItem.toJSONMinified(), + progressLastUpdate: oldMediaProgress.lastUpdate + }) } } } @@ -377,59 +333,67 @@ class MeController { }) } - // GET: api/me/series/:id/remove-from-continue-listening + /** + * GET: /api/me/series/:id/remove-from-continue-listening + * + * @param {RequestWithUser} req + * @param {Response} res + */ async removeSeriesFromContinueListening(req, res) { - const series = await Database.seriesModel.getOldById(req.params.id) - if (!series) { + if (!(await Database.seriesModel.checkExistsById(req.params.id))) { Logger.error(`[MeController] removeSeriesFromContinueListening: Series ${req.params.id} not found`) return res.sendStatus(404) } - const hasUpdated = req.user.addSeriesToHideFromContinueListening(req.params.id) + const hasUpdated = await req.userNew.addSeriesToHideFromContinueListening(req.params.id) if (hasUpdated) { - await Database.updateUser(req.user) - SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) + SocketAuthority.clientEmitter(req.userNew.id, 'user_updated', req.userNew.toOldJSONForBrowser()) } - res.json(req.user.toJSONForBrowser()) + res.json(req.userNew.toOldJSONForBrowser()) } - // GET: api/me/series/:id/readd-to-continue-listening + /** + * GET: api/me/series/:id/readd-to-continue-listening + * + * @param {RequestWithUser} req + * @param {Response} res + */ async readdSeriesFromContinueListening(req, res) { - const series = await Database.seriesModel.getOldById(req.params.id) - if (!series) { + if (!(await Database.seriesModel.checkExistsById(req.params.id))) { Logger.error(`[MeController] readdSeriesFromContinueListening: Series ${req.params.id} not found`) return res.sendStatus(404) } - const hasUpdated = req.user.removeSeriesFromHideFromContinueListening(req.params.id) + const hasUpdated = await req.userNew.removeSeriesFromHideFromContinueListening(req.params.id) if (hasUpdated) { - await Database.updateUser(req.user) - SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) + SocketAuthority.clientEmitter(req.userNew.id, 'user_updated', req.userNew.toOldJSONForBrowser()) } - res.json(req.user.toJSONForBrowser()) + res.json(req.userNew.toOldJSONForBrowser()) } - // GET: api/me/progress/:id/remove-from-continue-listening + /** + * GET: api/me/progress/:id/remove-from-continue-listening + * + * @param {RequestWithUser} req + * @param {Response} res + */ async removeItemFromContinueListening(req, res) { - const mediaProgress = req.user.mediaProgress.find((mp) => mp.id === req.params.id) + const mediaProgress = req.userNew.mediaProgresses.find((mp) => mp.id === req.params.id) if (!mediaProgress) { return res.sendStatus(404) } - const hasUpdated = req.user.removeProgressFromContinueListening(req.params.id) - if (hasUpdated) { - await Database.mediaProgressModel.update( - { - hideFromContinueListening: true - }, - { - where: { - id: mediaProgress.id - } - } - ) - SocketAuthority.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) + + // Already hidden + if (mediaProgress.hideFromContinueListening) { + return res.json(req.userNew.toOldJSONForBrowser()) } - res.json(req.user.toJSONForBrowser()) + + mediaProgress.hideFromContinueListening = true + await mediaProgress.save() + + SocketAuthority.clientEmitter(req.userNew.id, 'user_updated', req.userNew.toOldJSONForBrowser()) + + res.json(req.userNew.toOldJSONForBrowser()) } /** @@ -444,7 +408,7 @@ class MeController { Logger.error(`[MeController] Invalid year "${year}"`) return res.status(400).send('Invalid year') } - const data = await userStats.getStatsForYear(req.user, year) + const data = await userStats.getStatsForYear(req.userNew.id, year) res.json(data) } } diff --git a/server/models/User.js b/server/models/User.js index ef9e2bc043..54cfca5e82 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -425,6 +425,9 @@ class User extends Model { get isUser() { return this.type === 'user' } + get isGuest() { + return this.type === 'guest' + } get canAccessExplicitContent() { return !!this.permissions?.accessExplicitContent && this.isActive } @@ -780,6 +783,38 @@ class User extends Model { await this.save() return true } + + /** + * + * @param {string} seriesId + * @returns {Promise} + */ + async addSeriesToHideFromContinueListening(seriesId) { + if (!this.extraData) this.extraData = {} + const seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || [] + if (seriesHideFromContinueListening.includes(seriesId)) return false + seriesHideFromContinueListening.push(seriesId) + this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening + this.changed('extraData', true) + await this.save() + return true + } + + /** + * + * @param {string} seriesId + * @returns {Promise} + */ + async removeSeriesFromHideFromContinueListening(seriesId) { + if (!this.extraData) this.extraData = {} + let seriesHideFromContinueListening = this.extraData.seriesHideFromContinueListening || [] + if (!seriesHideFromContinueListening.includes(seriesId)) return false + seriesHideFromContinueListening = seriesHideFromContinueListening.filter((sid) => sid !== seriesId) + this.extraData.seriesHideFromContinueListening = seriesHideFromContinueListening + this.changed('extraData', true) + await this.save() + return true + } } module.exports = User diff --git a/server/objects/user/User.js b/server/objects/user/User.js index 14b49bcaa0..e76c3ac079 100644 --- a/server/objects/user/User.js +++ b/server/objects/user/User.js @@ -438,40 +438,6 @@ class User { return this.checkCanAccessLibraryItemWithTags(libraryItem.media.tags) } - /** - * Checks if a user can access a library item - * @param {string} libraryId - * @param {boolean} explicit - * @param {string[]} tags - */ - checkCanAccessLibraryItemWithData(libraryId, explicit, tags) { - if (!this.checkCanAccessLibrary(libraryId)) return false - if (explicit && !this.canAccessExplicitContent) return false - return this.checkCanAccessLibraryItemWithTags(tags) - } - - checkShouldHideSeriesFromContinueListening(seriesId) { - return this.seriesHideFromContinueListening.includes(seriesId) - } - - addSeriesToHideFromContinueListening(seriesId) { - if (this.seriesHideFromContinueListening.includes(seriesId)) return false - this.seriesHideFromContinueListening.push(seriesId) - return true - } - - removeSeriesFromHideFromContinueListening(seriesId) { - if (!this.seriesHideFromContinueListening.includes(seriesId)) return false - this.seriesHideFromContinueListening = this.seriesHideFromContinueListening.filter((sid) => sid !== seriesId) - return true - } - - removeProgressFromContinueListening(progressId) { - const progress = this.mediaProgress.find((mp) => mp.id === progressId) - if (!progress) return false - return progress.removeFromContinueListening() - } - /** * Number of podcast episodes not finished for library item * Note: libraryItem passed in from libraryHelpers is not a LibraryItem class instance diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 81dbc44cb0..283c2417f1 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -182,7 +182,6 @@ class ApiRouter { this.router.patch('/me/item/:id/bookmark', MeController.updateBookmark.bind(this)) this.router.delete('/me/item/:id/bookmark/:time', MeController.removeBookmark.bind(this)) this.router.patch('/me/password', MeController.updatePassword.bind(this)) - this.router.post('/me/sync-local-progress', MeController.syncLocalMediaProgress.bind(this)) // TODO: Deprecated. Removed from Android. Only used in iOS app now. this.router.get('/me/items-in-progress', MeController.getAllLibraryItemsInProgress.bind(this)) this.router.get('/me/series/:id/remove-from-continue-listening', MeController.removeSeriesFromContinueListening.bind(this)) this.router.get('/me/series/:id/readd-to-continue-listening', MeController.readdSeriesFromContinueListening.bind(this)) diff --git a/server/utils/queries/userStats.js b/server/utils/queries/userStats.js index 4e4080f8d3..76b69ed784 100644 --- a/server/utils/queries/userStats.js +++ b/server/utils/queries/userStats.js @@ -6,8 +6,8 @@ const fsExtra = require('../../libs/fsExtra') module.exports = { /** - * - * @param {string} userId + * + * @param {string} userId * @param {number} year YYYY * @returns {Promise} */ @@ -35,8 +35,8 @@ module.exports = { }, /** - * - * @param {string} userId + * + * @param {string} userId * @param {number} year YYYY * @returns {Promise} */ @@ -65,11 +65,10 @@ module.exports = { }, /** - * @param {import('../../objects/user/User')} user + * @param {string} userId * @param {number} year YYYY */ - async getStatsForYear(user, year) { - const userId = user.id + async getStatsForYear(userId, year) { const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year) @@ -91,7 +90,7 @@ module.exports = { let longestAudiobookFinished = null for (const mediaProgress of bookProgressesFinished) { // Grab first 5 that have a cover - if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) { + if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && (await fsExtra.pathExists(mediaProgress.mediaItem.coverPath))) { finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id) } @@ -108,7 +107,7 @@ module.exports = { // Get listening session stats for (const ls of listeningSessions) { // Grab first 25 that have a cover - if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { + if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && (await fsExtra.pathExists(ls.mediaItem.coverPath))) { booksWithCovers.push(ls.mediaItem.libraryItem.id) } @@ -141,7 +140,7 @@ module.exports = { }) // Filter out bad genres like "audiobook" and "audio book" - const genres = (ls.mediaMetadata.genres || []).filter(g => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) + const genres = (ls.mediaMetadata.genres || []).filter((g) => g && !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book')) genres.forEach((genre) => { if (!genreListeningMap[genre]) genreListeningMap[genre] = 0 genreListeningMap[genre] += listeningSessionListeningTime @@ -156,10 +155,13 @@ module.exports = { totalPodcastListeningTime = Math.round(totalPodcastListeningTime) let topAuthors = null - topAuthors = Object.keys(authorListeningMap).map(authorName => ({ - name: authorName, - time: Math.round(authorListeningMap[authorName]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topAuthors = Object.keys(authorListeningMap) + .map((authorName) => ({ + name: authorName, + time: Math.round(authorListeningMap[authorName]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let mostListenedNarrator = null for (const narrator in narratorListeningMap) { @@ -172,10 +174,13 @@ module.exports = { } let topGenres = null - topGenres = Object.keys(genreListeningMap).map(genre => ({ - genre, - time: Math.round(genreListeningMap[genre]) - })).sort((a, b) => b.time - a.time).slice(0, 3) + topGenres = Object.keys(genreListeningMap) + .map((genre) => ({ + genre, + time: Math.round(genreListeningMap[genre]) + })) + .sort((a, b) => b.time - a.time) + .slice(0, 3) let mostListenedMonth = null for (const month in monthListeningMap) {