mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			478 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			478 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.isPodcast && !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
 | 
						|
   */
 | 
						|
  async updatePassword(req, res) {
 | 
						|
    if (req.user.isGuest) {
 | 
						|
      Logger.error(`[MeController] Guest user "${req.user.username}" attempted to change password`)
 | 
						|
      return res.sendStatus(403)
 | 
						|
    }
 | 
						|
 | 
						|
    const { password, newPassword } = req.body
 | 
						|
    if ((typeof password !== 'string' && password !== null) || (typeof newPassword !== 'string' && newPassword !== null)) {
 | 
						|
      return res.status(400).send('Missing or invalid password or new password')
 | 
						|
    }
 | 
						|
 | 
						|
    const result = await this.auth.localAuthStrategy.changePassword(req.user, password, newPassword)
 | 
						|
 | 
						|
    if (result.error) {
 | 
						|
      return res.status(400).send(result.error)
 | 
						|
    }
 | 
						|
 | 
						|
    res.sendStatus(200)
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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.findAllExpandedWhere({ 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.isPodcast) {
 | 
						|
          const episode = libraryItem.media.podcastEpisodes.find((ep) => ep.id === oldMediaProgress.episodeId)
 | 
						|
          if (episode) {
 | 
						|
            const libraryItemWithEpisode = {
 | 
						|
              ...libraryItem.toOldJSONMinified(),
 | 
						|
              recentEpisode: episode.toOldJSON(libraryItem.id),
 | 
						|
              progressLastUpdate: oldMediaProgress.lastUpdate
 | 
						|
            }
 | 
						|
            itemsInProgress.push(libraryItemWithEpisode)
 | 
						|
          }
 | 
						|
        } else if (!oldMediaProgress.episodeId) {
 | 
						|
          itemsInProgress.push({
 | 
						|
            ...libraryItem.toOldJSONMinified(),
 | 
						|
            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.getEReaderDevices(req.user)
 | 
						|
      })
 | 
						|
    }
 | 
						|
    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()
 |