mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			1454 lines
		
	
	
		
			50 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1454 lines
		
	
	
		
			50 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const { Request, Response, NextFunction } = require('express')
 | |
| const Sequelize = require('sequelize')
 | |
| const Path = require('path')
 | |
| const fs = require('../libs/fsExtra')
 | |
| const Logger = require('../Logger')
 | |
| const SocketAuthority = require('../SocketAuthority')
 | |
| const libraryHelpers = require('../utils/libraryHelpers')
 | |
| const libraryItemsBookFilters = require('../utils/queries/libraryItemsBookFilters')
 | |
| const libraryItemFilters = require('../utils/queries/libraryItemFilters')
 | |
| const seriesFilters = require('../utils/queries/seriesFilters')
 | |
| const fileUtils = require('../utils/fileUtils')
 | |
| const { createNewSortInstance } = require('../libs/fastSort')
 | |
| const naturalSort = createNewSortInstance({
 | |
|   comparer: new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }).compare
 | |
| })
 | |
| 
 | |
| const LibraryScanner = require('../scanner/LibraryScanner')
 | |
| const Scanner = require('../scanner/Scanner')
 | |
| const Database = require('../Database')
 | |
| const Watcher = require('../Watcher')
 | |
| const RssFeedManager = require('../managers/RssFeedManager')
 | |
| 
 | |
| const libraryFilters = require('../utils/queries/libraryFilters')
 | |
| const libraryItemsPodcastFilters = require('../utils/queries/libraryItemsPodcastFilters')
 | |
| const authorFilters = require('../utils/queries/authorFilters')
 | |
| 
 | |
| /**
 | |
|  * @typedef RequestUserObject
 | |
|  * @property {import('../models/User')} user
 | |
|  *
 | |
|  * @typedef {Request & RequestUserObject} RequestWithUser
 | |
|  *
 | |
|  * @typedef RequestEntityObject
 | |
|  * @property {import('../models/Library')} library
 | |
|  *
 | |
|  * @typedef {RequestWithUser & RequestEntityObject} LibraryControllerRequest
 | |
|  */
 | |
| 
 | |
| class LibraryController {
 | |
|   constructor() {}
 | |
| 
 | |
|   /**
 | |
|    * POST: /api/libraries
 | |
|    * Create a new library
 | |
|    *
 | |
|    * @param {RequestWithUser} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async create(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to create library`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     // Validation
 | |
|     if (!req.body.name || typeof req.body.name !== 'string') {
 | |
|       return res.status(400).send('Invalid request. Name must be a string')
 | |
|     }
 | |
|     if (
 | |
|       !Array.isArray(req.body.folders) ||
 | |
|       req.body.folders.some((f) => {
 | |
|         // Old model uses fullPath and new model will use path. Support both for now
 | |
|         const path = f?.fullPath || f?.path
 | |
|         return !path || typeof path !== 'string'
 | |
|       })
 | |
|     ) {
 | |
|       return res.status(400).send('Invalid request. Folders must be a non-empty array of objects with path string')
 | |
|     }
 | |
|     const optionalStringFields = ['mediaType', 'icon', 'provider']
 | |
|     for (const field of optionalStringFields) {
 | |
|       if (req.body[field] && typeof req.body[field] !== 'string') {
 | |
|         return res.status(400).send(`Invalid request. ${field} must be a string`)
 | |
|       }
 | |
|     }
 | |
|     if (req.body.settings && (typeof req.body.settings !== 'object' || Array.isArray(req.body.settings))) {
 | |
|       return res.status(400).send('Invalid request. Settings must be an object')
 | |
|     }
 | |
| 
 | |
|     const mediaType = req.body.mediaType || 'book'
 | |
|     const newLibraryPayload = {
 | |
|       name: req.body.name,
 | |
|       provider: req.body.provider || 'google',
 | |
|       mediaType,
 | |
|       icon: req.body.icon || 'database',
 | |
|       settings: Database.libraryModel.getDefaultLibrarySettingsForMediaType(mediaType)
 | |
|     }
 | |
| 
 | |
|     // Validate settings
 | |
|     if (req.body.settings) {
 | |
|       for (const key in req.body.settings) {
 | |
|         if (newLibraryPayload.settings[key] !== undefined) {
 | |
|           if (key === 'metadataPrecedence') {
 | |
|             if (!Array.isArray(req.body.settings[key])) {
 | |
|               return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array')
 | |
|             }
 | |
|             newLibraryPayload.settings[key] = [...req.body.settings[key]]
 | |
|           } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
 | |
|             if (!req.body.settings[key]) continue
 | |
|             if (typeof req.body.settings[key] !== 'string') {
 | |
|               return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
 | |
|             }
 | |
|             newLibraryPayload.settings[key] = req.body.settings[key]
 | |
|           } else if (key === 'markAsFinishedPercentComplete' || key === 'markAsFinishedTimeRemaining') {
 | |
|             if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
 | |
|               return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
 | |
|             } else if (key === 'markAsFinishedPercentComplete' && req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
 | |
|               return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
 | |
|             } else if (key === 'markAsFinishedTimeRemaining' && req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
 | |
|               return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
 | |
|             }
 | |
|             newLibraryPayload.settings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
 | |
|           } else {
 | |
|             if (typeof req.body.settings[key] !== typeof newLibraryPayload.settings[key]) {
 | |
|               return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof newLibraryPayload.settings[key]}`)
 | |
|             }
 | |
|             newLibraryPayload.settings[key] = req.body.settings[key]
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Validate that the custom provider exists if given any
 | |
|     if (newLibraryPayload.provider.startsWith('custom-')) {
 | |
|       if (!(await Database.customMetadataProviderModel.checkExistsBySlug(newLibraryPayload.provider))) {
 | |
|         Logger.error(`[LibraryController] Custom metadata provider "${newLibraryPayload.provider}" does not exist`)
 | |
|         return res.status(400).send('Invalid request. Custom metadata provider does not exist')
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Validate folder paths exist or can be created & resolve rel paths
 | |
|     //   returns 400 if a folder fails to access
 | |
|     newLibraryPayload.libraryFolders = req.body.folders.map((f) => {
 | |
|       const fpath = f.fullPath || f.path
 | |
|       f.path = fileUtils.filePathToPOSIX(Path.resolve(fpath))
 | |
|       return f
 | |
|     })
 | |
|     for (const folder of newLibraryPayload.libraryFolders) {
 | |
|       try {
 | |
|         // Create folder if it doesn't exist
 | |
|         await fs.ensureDir(folder.path)
 | |
|       } catch (error) {
 | |
|         Logger.error(`[LibraryController] Failed to ensure folder dir "${folder.path}"`, error)
 | |
|         return res.status(400).send(`Invalid request. Invalid folder directory "${folder.path}"`)
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Set display order
 | |
|     let currentLargestDisplayOrder = await Database.libraryModel.getMaxDisplayOrder()
 | |
|     if (isNaN(currentLargestDisplayOrder)) currentLargestDisplayOrder = 0
 | |
|     newLibraryPayload.displayOrder = currentLargestDisplayOrder + 1
 | |
| 
 | |
|     // Create library with libraryFolders
 | |
|     const library = await Database.libraryModel
 | |
|       .create(newLibraryPayload, {
 | |
|         include: Database.libraryFolderModel
 | |
|       })
 | |
|       .catch((error) => {
 | |
|         Logger.error(`[LibraryController] Failed to create library "${newLibraryPayload.name}"`, error)
 | |
|       })
 | |
|     if (!library) {
 | |
|       return res.status(500).send('Failed to create library')
 | |
|     }
 | |
| 
 | |
|     library.libraryFolders = await library.getLibraryFolders()
 | |
| 
 | |
|     // Only emit to users with access to library
 | |
|     const userFilter = (user) => {
 | |
|       return user.checkCanAccessLibrary?.(library.id)
 | |
|     }
 | |
|     SocketAuthority.emitter('library_added', library.toOldJSON(), userFilter)
 | |
| 
 | |
|     // Add library watcher
 | |
|     Watcher.addLibrary(library)
 | |
| 
 | |
|     res.json(library.toOldJSON())
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries
 | |
|    * Get all libraries
 | |
|    *
 | |
|    * ?include=stats to load library stats - used in android auto to filter out libraries with no audio
 | |
|    *
 | |
|    * @param {RequestWithUser} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async findAll(req, res) {
 | |
|     let libraries = await Database.libraryModel.getAllWithFolders()
 | |
| 
 | |
|     const librariesAccessible = req.user.permissions?.librariesAccessible || []
 | |
|     if (librariesAccessible.length) {
 | |
|       libraries = libraries.filter((lib) => librariesAccessible.includes(lib.id))
 | |
|     }
 | |
| 
 | |
|     libraries = libraries.map((lib) => lib.toOldJSON())
 | |
| 
 | |
|     const includeArray = (req.query.include || '').split(',')
 | |
|     if (includeArray.includes('stats')) {
 | |
|       for (const library of libraries) {
 | |
|         if (library.mediaType === 'book') {
 | |
|           library.stats = await libraryItemsBookFilters.getBookLibraryStats(library.id)
 | |
|         } else if (library.mediaType === 'podcast') {
 | |
|           library.stats = await libraryItemsPodcastFilters.getPodcastLibraryStats(library.id)
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       libraries
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async findOne(req, res) {
 | |
|     const includeArray = (req.query.include || '').split(',')
 | |
|     if (includeArray.includes('filterdata')) {
 | |
|       const filterdata = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
 | |
|       const customMetadataProviders = await Database.customMetadataProviderModel.getForClientByMediaType(req.library.mediaType)
 | |
| 
 | |
|       return res.json({
 | |
|         filterdata,
 | |
|         issues: filterdata.numIssues,
 | |
|         numUserPlaylists: await Database.playlistModel.getNumPlaylistsForUserAndLibrary(req.user.id, req.library.id),
 | |
|         customMetadataProviders,
 | |
|         library: req.library.toOldJSON()
 | |
|       })
 | |
|     }
 | |
|     res.json(req.library.toOldJSON())
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/episode-downloads
 | |
|    * Get podcast episodes in download queue
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getEpisodeDownloadQueue(req, res) {
 | |
|     const libraryDownloadQueueDetails = this.podcastManager.getDownloadQueueDetails(req.library.id)
 | |
|     res.json(libraryDownloadQueueDetails)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * PATCH: /api/libraries/:id
 | |
|    *
 | |
|    * @this {import('../routers/ApiRouter')}
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async update(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to update library`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     // Validation
 | |
|     const updatePayload = {}
 | |
|     const keysToCheck = ['name', 'provider', 'mediaType', 'icon']
 | |
|     for (const key of keysToCheck) {
 | |
|       if (!req.body[key]) continue
 | |
|       if (typeof req.body[key] !== 'string') {
 | |
|         Logger.error(`[LibraryController] Invalid request. ${key} must be a string`)
 | |
|         return res.status(400).send(`Invalid request. ${key} must be a string`)
 | |
|       }
 | |
|       updatePayload[key] = req.body[key]
 | |
|     }
 | |
|     if (req.body.displayOrder !== undefined) {
 | |
|       if (isNaN(req.body.displayOrder)) {
 | |
|         Logger.error(`[LibraryController] Invalid request. displayOrder must be a number`)
 | |
|         return res.status(400).send('Invalid request. displayOrder must be a number')
 | |
|       }
 | |
|       updatePayload.displayOrder = req.body.displayOrder
 | |
|     }
 | |
| 
 | |
|     // Validate that the custom provider exists if given any
 | |
|     if (req.body.provider?.startsWith('custom-')) {
 | |
|       if (!(await Database.customMetadataProviderModel.checkExistsBySlug(req.body.provider))) {
 | |
|         Logger.error(`[LibraryController] Custom metadata provider "${req.body.provider}" does not exist`)
 | |
|         return res.status(400).send('Custom metadata provider does not exist')
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     // Validate settings
 | |
|     const defaultLibrarySettings = Database.libraryModel.getDefaultLibrarySettingsForMediaType(req.library.mediaType)
 | |
|     const updatedSettings = {
 | |
|       ...(req.library.settings || defaultLibrarySettings)
 | |
|     }
 | |
|     // In case new settings are added in the future, ensure all settings are present
 | |
|     for (const key in defaultLibrarySettings) {
 | |
|       if (updatedSettings[key] === undefined) {
 | |
|         updatedSettings[key] = defaultLibrarySettings[key]
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     let hasUpdates = false
 | |
|     let hasUpdatedDisableWatcher = false
 | |
|     let hasUpdatedScanCron = false
 | |
|     if (req.body.settings) {
 | |
|       for (const key in req.body.settings) {
 | |
|         if (!Object.keys(defaultLibrarySettings).includes(key)) {
 | |
|           continue
 | |
|         }
 | |
| 
 | |
|         if (key === 'metadataPrecedence') {
 | |
|           if (!Array.isArray(req.body.settings[key])) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Settings "metadataPrecedence" must be an array`)
 | |
|             return res.status(400).send('Invalid request. Settings "metadataPrecedence" must be an array')
 | |
|           }
 | |
|           if (JSON.stringify(req.body.settings[key]) !== JSON.stringify(updatedSettings[key])) {
 | |
|             hasUpdates = true
 | |
|             updatedSettings[key] = [...req.body.settings[key]]
 | |
|             Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
 | |
|           }
 | |
|         } else if (key === 'autoScanCronExpression' || key === 'podcastSearchRegion') {
 | |
|           if (req.body.settings[key] !== null && typeof req.body.settings[key] !== 'string') {
 | |
|             Logger.error(`[LibraryController] Invalid request. Settings "${key}" must be a string`)
 | |
|             return res.status(400).send(`Invalid request. Settings "${key}" must be a string`)
 | |
|           }
 | |
|           if (req.body.settings[key] !== updatedSettings[key]) {
 | |
|             if (key === 'autoScanCronExpression') hasUpdatedScanCron = true
 | |
| 
 | |
|             hasUpdates = true
 | |
|             updatedSettings[key] = req.body.settings[key]
 | |
|             Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
 | |
|           }
 | |
|         } else if (key === 'markAsFinishedPercentComplete') {
 | |
|           if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
 | |
|             return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
 | |
|           } else if (req.body.settings[key] !== null && (Number(req.body.settings[key]) < 0 || Number(req.body.settings[key]) > 100)) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be between 0 and 100`)
 | |
|             return res.status(400).send(`Invalid request. Setting "${key}" must be between 0 and 100`)
 | |
|           }
 | |
|           if (req.body.settings[key] !== updatedSettings[key]) {
 | |
|             hasUpdates = true
 | |
|             updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
 | |
|             Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
 | |
|           }
 | |
|         } else if (key === 'markAsFinishedTimeRemaining') {
 | |
|           if (req.body.settings[key] !== null && isNaN(req.body.settings[key])) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be a number`)
 | |
|             return res.status(400).send(`Invalid request. Setting "${key}" must be a number`)
 | |
|           } else if (req.body.settings[key] !== null && Number(req.body.settings[key]) < 0) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be greater than or equal to 0`)
 | |
|             return res.status(400).send(`Invalid request. Setting "${key}" must be greater than or equal to 0`)
 | |
|           }
 | |
|           if (req.body.settings[key] !== updatedSettings[key]) {
 | |
|             hasUpdates = true
 | |
|             updatedSettings[key] = req.body.settings[key] === null ? null : Number(req.body.settings[key])
 | |
|             Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
 | |
|           }
 | |
|         } else {
 | |
|           if (typeof req.body.settings[key] !== typeof updatedSettings[key]) {
 | |
|             Logger.error(`[LibraryController] Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
 | |
|             return res.status(400).send(`Invalid request. Setting "${key}" must be of type ${typeof updatedSettings[key]}`)
 | |
|           }
 | |
|           if (req.body.settings[key] !== updatedSettings[key]) {
 | |
|             if (key === 'disableWatcher') hasUpdatedDisableWatcher = true
 | |
| 
 | |
|             hasUpdates = true
 | |
|             updatedSettings[key] = req.body.settings[key]
 | |
|             Logger.debug(`[LibraryController] Library "${req.library.name}" updating setting "${key}" to "${updatedSettings[key]}"`)
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|       if (hasUpdates) {
 | |
|         updatePayload.settings = updatedSettings
 | |
|         req.library.changed('settings', true)
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     let hasFolderUpdates = false
 | |
|     // Validate new folder paths exist or can be created & resolve rel paths
 | |
|     //   returns 400 if a new folder fails to access
 | |
|     if (Array.isArray(req.body.folders)) {
 | |
|       const newFolderPaths = []
 | |
|       req.body.folders = req.body.folders.map((f) => {
 | |
|         if (!f.id) {
 | |
|           const path = f.fullPath || f.path
 | |
|           f.path = fileUtils.filePathToPOSIX(Path.resolve(path))
 | |
|           newFolderPaths.push(f.path)
 | |
|         }
 | |
|         return f
 | |
|       })
 | |
|       for (const path of newFolderPaths) {
 | |
|         const pathExists = await fs.pathExists(path)
 | |
|         if (!pathExists) {
 | |
|           const success = await fs
 | |
|             .ensureDir(path)
 | |
|             .then(() => true)
 | |
|             .catch((error) => {
 | |
|               Logger.error(`[LibraryController] Failed to ensure folder dir "${path}"`, error)
 | |
|               return false
 | |
|             })
 | |
|           if (!success) {
 | |
|             Logger.error(`[LibraryController] Invalid folder directory "${path}"`)
 | |
|             return res.status(400).send(`Invalid folder directory "${path}"`)
 | |
|           }
 | |
|         }
 | |
|         // Create folder
 | |
|         const libraryFolder = await Database.libraryFolderModel.create({
 | |
|           path,
 | |
|           libraryId: req.library.id
 | |
|         })
 | |
|         Logger.info(`[LibraryController] Created folder "${libraryFolder.path}" for library "${req.library.name}"`)
 | |
|         hasFolderUpdates = true
 | |
|       }
 | |
| 
 | |
|       // Handle removing folders
 | |
|       for (const folder of req.library.libraryFolders) {
 | |
|         if (!req.body.folders.some((f) => f.id === folder.id)) {
 | |
|           // Remove library items in folder
 | |
|           const libraryItemsInFolder = await Database.libraryItemModel.findAll({
 | |
|             where: {
 | |
|               libraryFolderId: folder.id
 | |
|             },
 | |
|             attributes: ['id', 'mediaId', 'mediaType'],
 | |
|             include: [
 | |
|               {
 | |
|                 model: Database.podcastModel,
 | |
|                 attributes: ['id'],
 | |
|                 include: {
 | |
|                   model: Database.podcastEpisodeModel,
 | |
|                   attributes: ['id']
 | |
|                 }
 | |
|               },
 | |
|               {
 | |
|                 model: Database.bookModel,
 | |
|                 attributes: ['id'],
 | |
|                 include: [
 | |
|                   {
 | |
|                     model: Database.bookAuthorModel,
 | |
|                     attributes: ['authorId']
 | |
|                   },
 | |
|                   {
 | |
|                     model: Database.bookSeriesModel,
 | |
|                     attributes: ['seriesId']
 | |
|                   }
 | |
|                 ]
 | |
|               }
 | |
|             ]
 | |
|           })
 | |
|           Logger.info(`[LibraryController] Removed folder "${folder.path}" from library "${req.library.name}" with ${libraryItemsInFolder.length} library items`)
 | |
|           const seriesIds = []
 | |
|           const authorIds = []
 | |
|           for (const libraryItem of libraryItemsInFolder) {
 | |
|             let mediaItemIds = []
 | |
|             if (req.library.isPodcast) {
 | |
|               mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
 | |
|             } else {
 | |
|               mediaItemIds.push(libraryItem.mediaId)
 | |
|               if (libraryItem.media.bookAuthors.length) {
 | |
|                 authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
 | |
|               }
 | |
|               if (libraryItem.media.bookSeries.length) {
 | |
|                 seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
 | |
|               }
 | |
|             }
 | |
|             Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from folder "${folder.path}"`)
 | |
|             await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | |
|           }
 | |
| 
 | |
|           if (authorIds.length) {
 | |
|             await this.checkRemoveAuthorsWithNoBooks(authorIds)
 | |
|           }
 | |
|           if (seriesIds.length) {
 | |
|             await this.checkRemoveEmptySeries(seriesIds)
 | |
|           }
 | |
| 
 | |
|           // Remove folder
 | |
|           await folder.destroy()
 | |
|           hasFolderUpdates = true
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (Object.keys(updatePayload).length) {
 | |
|       req.library.set(updatePayload)
 | |
|       if (req.library.changed()) {
 | |
|         Logger.debug(`[LibraryController] Updated library "${req.library.name}" with changed keys ${req.library.changed()}`)
 | |
|         hasUpdates = true
 | |
|         await req.library.save()
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (hasUpdatedScanCron) {
 | |
|       Logger.debug(`[LibraryController] Updated library "${req.library.name}" auto scan cron`)
 | |
|       // Update auto scan cron
 | |
|       this.cronManager.updateLibraryScanCron(req.library)
 | |
|     }
 | |
| 
 | |
|     if (hasFolderUpdates || hasUpdatedDisableWatcher) {
 | |
|       req.library.libraryFolders = await req.library.getLibraryFolders()
 | |
| 
 | |
|       // Update watcher
 | |
|       Watcher.updateLibrary(req.library)
 | |
| 
 | |
|       hasUpdates = true
 | |
|     }
 | |
| 
 | |
|     if (hasUpdates) {
 | |
|       // Only emit to users with access to library
 | |
|       const userFilter = (user) => {
 | |
|         return user.checkCanAccessLibrary?.(req.library.id)
 | |
|       }
 | |
|       SocketAuthority.emitter('library_updated', req.library.toOldJSON(), userFilter)
 | |
| 
 | |
|       await Database.resetLibraryIssuesFilterData(req.library.id)
 | |
|     }
 | |
|     return res.json(req.library.toOldJSON())
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * DELETE: /api/libraries/:id
 | |
|    * Delete a library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async delete(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
|     
 | |
|     // Remove library watcher
 | |
|     Watcher.removeLibrary(req.library)
 | |
| 
 | |
|     // Remove collections for library
 | |
|     const numCollectionsRemoved = await Database.collectionModel.removeAllForLibrary(req.library.id)
 | |
|     if (numCollectionsRemoved) {
 | |
|       Logger.info(`[Server] Removed ${numCollectionsRemoved} collections for library "${req.library.name}"`)
 | |
|     }
 | |
| 
 | |
|     // Remove items in this library
 | |
|     const libraryItemsInLibrary = await Database.libraryItemModel.findAll({
 | |
|       where: {
 | |
|         libraryId: req.library.id
 | |
|       },
 | |
|       attributes: ['id', 'mediaId', 'mediaType'],
 | |
|       include: [
 | |
|         {
 | |
|           model: Database.podcastModel,
 | |
|           attributes: ['id'],
 | |
|           include: {
 | |
|             model: Database.podcastEpisodeModel,
 | |
|             attributes: ['id']
 | |
|           }
 | |
|         }
 | |
|       ]
 | |
|     })
 | |
|     Logger.info(`[LibraryController] Removing ${libraryItemsInLibrary.length} library items in library "${req.library.name}"`)
 | |
|     for (const libraryItem of libraryItemsInLibrary) {
 | |
|       let mediaItemIds = []
 | |
|       if (req.library.isPodcast) {
 | |
|         mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
 | |
|       } else {
 | |
|         mediaItemIds.push(libraryItem.mediaId)
 | |
|       }
 | |
|       Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
 | |
|       await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | |
|     }
 | |
| 
 | |
|     // Set PlaybackSessions libraryId to null
 | |
|     const [sessionsUpdated] = await Database.playbackSessionModel.update(
 | |
|       {
 | |
|         libraryId: null
 | |
|       },
 | |
|       {
 | |
|         where: {
 | |
|           libraryId: req.library.id
 | |
|         }
 | |
|       }
 | |
|     )
 | |
|     Logger.info(`[LibraryController] Updated ${sessionsUpdated} playback sessions to remove library id`)
 | |
| 
 | |
|     const libraryJson = req.library.toOldJSON()
 | |
|     await req.library.destroy()
 | |
| 
 | |
|     // Re-order libraries
 | |
|     await Database.libraryModel.resetDisplayOrder()
 | |
| 
 | |
|     SocketAuthority.emitter('library_removed', libraryJson)
 | |
| 
 | |
|     // Remove library filter data
 | |
|     if (Database.libraryFilterData[req.library.id]) {
 | |
|       delete Database.libraryFilterData[req.library.id]
 | |
|     }
 | |
| 
 | |
|     return res.json(libraryJson)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET /api/libraries/:id/items
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getLibraryItems(req, res) {
 | |
|     const include = (req.query.include || '')
 | |
|       .split(',')
 | |
|       .map((v) => v.trim().toLowerCase())
 | |
|       .filter((v) => !!v)
 | |
| 
 | |
|     const payload = {
 | |
|       results: [],
 | |
|       total: undefined,
 | |
|       limit: req.query.limit || 0,
 | |
|       page: req.query.page || 0,
 | |
|       sortBy: req.query.sort,
 | |
|       sortDesc: req.query.desc === '1',
 | |
|       filterBy: req.query.filter,
 | |
|       mediaType: req.library.mediaType,
 | |
|       minified: req.query.minified === '1',
 | |
|       collapseseries: req.query.collapseseries === '1',
 | |
|       include: include.join(',')
 | |
|     }
 | |
| 
 | |
|     payload.offset = payload.page * payload.limit
 | |
| 
 | |
|     // TODO: Temporary way of handling collapse sub-series. Either remove feature or handle through sql queries
 | |
|     const filterByGroup = payload.filterBy?.split('.').shift()
 | |
|     const filterByValue = filterByGroup ? libraryFilters.decode(payload.filterBy.replace(`${filterByGroup}.`, '')) : null
 | |
|     if (filterByGroup === 'series' && filterByValue !== 'no-series' && payload.collapseseries) {
 | |
|       const seriesId = libraryFilters.decode(payload.filterBy.split('.')[1])
 | |
|       payload.results = await libraryHelpers.handleCollapseSubseries(payload, seriesId, req.user, req.library)
 | |
|     } else {
 | |
|       const { libraryItems, count } = await Database.libraryItemModel.getByFilterAndSort(req.library, req.user, payload)
 | |
|       payload.results = libraryItems
 | |
|       payload.total = count
 | |
|     }
 | |
| 
 | |
|     res.json(payload)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * DELETE: /api/libraries/:id/issues
 | |
|    * Remove all library items missing or invalid
 | |
|    *
 | |
|    * @this {import('../routers/ApiRouter')}
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async removeLibraryItemsWithIssues(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to delete library items missing or invalid`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const libraryItemsWithIssues = await Database.libraryItemModel.findAll({
 | |
|       where: {
 | |
|         libraryId: req.library.id,
 | |
|         [Sequelize.Op.or]: [
 | |
|           {
 | |
|             isMissing: true
 | |
|           },
 | |
|           {
 | |
|             isInvalid: true
 | |
|           }
 | |
|         ]
 | |
|       },
 | |
|       attributes: ['id', 'mediaId', 'mediaType'],
 | |
|       include: [
 | |
|         {
 | |
|           model: Database.podcastModel,
 | |
|           attributes: ['id'],
 | |
|           include: {
 | |
|             model: Database.podcastEpisodeModel,
 | |
|             attributes: ['id']
 | |
|           }
 | |
|         },
 | |
|         {
 | |
|           model: Database.bookModel,
 | |
|           attributes: ['id'],
 | |
|           include: [
 | |
|             {
 | |
|               model: Database.bookAuthorModel,
 | |
|               attributes: ['authorId']
 | |
|             },
 | |
|             {
 | |
|               model: Database.bookSeriesModel,
 | |
|               attributes: ['seriesId']
 | |
|             }
 | |
|           ]
 | |
|         }
 | |
|       ]
 | |
|     })
 | |
| 
 | |
|     if (!libraryItemsWithIssues.length) {
 | |
|       Logger.warn(`[LibraryController] No library items have issues`)
 | |
|       return res.sendStatus(200)
 | |
|     }
 | |
| 
 | |
|     Logger.info(`[LibraryController] Removing ${libraryItemsWithIssues.length} items with issues`)
 | |
|     const authorIds = []
 | |
|     const seriesIds = []
 | |
|     for (const libraryItem of libraryItemsWithIssues) {
 | |
|       let mediaItemIds = []
 | |
|       if (req.library.isPodcast) {
 | |
|         mediaItemIds = libraryItem.media.podcastEpisodes.map((pe) => pe.id)
 | |
|       } else {
 | |
|         mediaItemIds.push(libraryItem.mediaId)
 | |
|         if (libraryItem.media.bookAuthors.length) {
 | |
|           authorIds.push(...libraryItem.media.bookAuthors.map((ba) => ba.authorId))
 | |
|         }
 | |
|         if (libraryItem.media.bookSeries.length) {
 | |
|           seriesIds.push(...libraryItem.media.bookSeries.map((bs) => bs.seriesId))
 | |
|         }
 | |
|       }
 | |
|       Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" with issue`)
 | |
|       await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | |
|     }
 | |
| 
 | |
|     if (authorIds.length) {
 | |
|       await this.checkRemoveAuthorsWithNoBooks(authorIds)
 | |
|     }
 | |
|     if (seriesIds.length) {
 | |
|       await this.checkRemoveEmptySeries(seriesIds)
 | |
|     }
 | |
| 
 | |
|     // Set numIssues to 0 for library filter data
 | |
|     if (Database.libraryFilterData[req.library.id]) {
 | |
|       Database.libraryFilterData[req.library.id].numIssues = 0
 | |
|     }
 | |
| 
 | |
|     res.sendStatus(200)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/series
 | |
|    * Optional query string: `?include=rssfeed` that adds `rssFeed` to series if a feed is open
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getAllSeriesForLibrary(req, res) {
 | |
|     const include = (req.query.include || '')
 | |
|       .split(',')
 | |
|       .map((v) => v.trim().toLowerCase())
 | |
|       .filter((v) => !!v)
 | |
| 
 | |
|     const payload = {
 | |
|       results: [],
 | |
|       total: 0,
 | |
|       limit: req.query.limit || 0,
 | |
|       page: req.query.page || 0,
 | |
|       sortBy: req.query.sort,
 | |
|       sortDesc: req.query.desc === '1',
 | |
|       filterBy: req.query.filter,
 | |
|       minified: req.query.minified === '1',
 | |
|       include: include.join(',')
 | |
|     }
 | |
| 
 | |
|     const offset = payload.page * payload.limit
 | |
|     const { series, count } = await seriesFilters.getFilteredSeries(req.library, req.user, payload.filterBy, payload.sortBy, payload.sortDesc, include, payload.limit, offset)
 | |
| 
 | |
|     payload.total = count
 | |
|     payload.results = series
 | |
|     res.json(payload)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/series/:seriesId
 | |
|    *
 | |
|    * Optional includes (e.g. `?include=rssfeed,progress`)
 | |
|    * rssfeed: adds `rssFeed` to series object if a feed is open
 | |
|    * progress: adds `progress` to series object with { libraryItemIds:Array<llid>, libraryItemIdsFinished:Array<llid>, isFinished:boolean }
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res - Series
 | |
|    */
 | |
|   async getSeriesForLibrary(req, res) {
 | |
|     const include = (req.query.include || '')
 | |
|       .split(',')
 | |
|       .map((v) => v.trim().toLowerCase())
 | |
|       .filter((v) => !!v)
 | |
| 
 | |
|     const series = await Database.seriesModel.findByPk(req.params.seriesId)
 | |
|     if (!series) return res.sendStatus(404)
 | |
| 
 | |
|     const libraryItemsInSeries = await libraryItemsBookFilters.getLibraryItemsForSeries(series, req.user)
 | |
| 
 | |
|     const seriesJson = series.toOldJSON()
 | |
|     if (include.includes('progress')) {
 | |
|       const libraryItemsFinished = libraryItemsInSeries.filter((li) => !!req.user.getMediaProgress(li.media.id)?.isFinished)
 | |
|       seriesJson.progress = {
 | |
|         libraryItemIds: libraryItemsInSeries.map((li) => li.id),
 | |
|         libraryItemIdsFinished: libraryItemsFinished.map((li) => li.id),
 | |
|         isFinished: libraryItemsFinished.length >= libraryItemsInSeries.length
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (include.includes('rssfeed')) {
 | |
|       const feedObj = await RssFeedManager.findFeedForEntityId(seriesJson.id)
 | |
|       seriesJson.rssFeed = feedObj?.toOldJSONMinified() || null
 | |
|     }
 | |
| 
 | |
|     res.json(seriesJson)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/collections
 | |
|    * Get all collections for library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getCollectionsForLibrary(req, res) {
 | |
|     const include = (req.query.include || '')
 | |
|       .split(',')
 | |
|       .map((v) => v.trim().toLowerCase())
 | |
|       .filter((v) => !!v)
 | |
| 
 | |
|     const payload = {
 | |
|       results: [],
 | |
|       total: 0,
 | |
|       limit: req.query.limit || 0,
 | |
|       page: req.query.page || 0,
 | |
|       sortBy: req.query.sort,
 | |
|       sortDesc: req.query.desc === '1',
 | |
|       filterBy: req.query.filter,
 | |
|       minified: req.query.minified === '1',
 | |
|       include: include.join(',')
 | |
|     }
 | |
| 
 | |
|     // TODO: Create paginated queries
 | |
|     let collections = await Database.collectionModel.getOldCollectionsJsonExpanded(req.user, req.library.id, include)
 | |
| 
 | |
|     payload.total = collections.length
 | |
| 
 | |
|     if (payload.limit) {
 | |
|       const startIndex = payload.page * payload.limit
 | |
|       collections = collections.slice(startIndex, startIndex + payload.limit)
 | |
|     }
 | |
| 
 | |
|     payload.results = collections
 | |
|     res.json(payload)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/playlists
 | |
|    * Get playlists for user in library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getUserPlaylistsForLibrary(req, res) {
 | |
|     let playlistsForUser = await Database.playlistModel.getOldPlaylistsForUserAndLibrary(req.user.id, req.library.id)
 | |
| 
 | |
|     const payload = {
 | |
|       results: [],
 | |
|       total: playlistsForUser.length,
 | |
|       limit: req.query.limit || 0,
 | |
|       page: req.query.page || 0
 | |
|     }
 | |
| 
 | |
|     if (payload.limit) {
 | |
|       const startIndex = payload.page * payload.limit
 | |
|       playlistsForUser = playlistsForUser.slice(startIndex, startIndex + payload.limit)
 | |
|     }
 | |
| 
 | |
|     payload.results = playlistsForUser
 | |
|     res.json(payload)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/filterdata
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getLibraryFilterData(req, res) {
 | |
|     const filterData = await libraryFilters.getFilterData(req.library.mediaType, req.library.id)
 | |
|     res.json(filterData)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/personalized
 | |
|    * Home page shelves
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getUserPersonalizedShelves(req, res) {
 | |
|     const limitPerShelf = req.query.limit || 10
 | |
|     const include = (req.query.include || '')
 | |
|       .split(',')
 | |
|       .map((v) => v.trim().toLowerCase())
 | |
|       .filter((v) => !!v)
 | |
|     const shelves = await Database.libraryItemModel.getPersonalizedShelves(req.library, req.user, include, limitPerShelf)
 | |
|     res.json(shelves)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * POST: /api/libraries/order
 | |
|    * Change the display order of libraries
 | |
|    *
 | |
|    * @typedef LibraryReorderObj
 | |
|    * @property {string} id
 | |
|    * @property {number} newOrder
 | |
|    *
 | |
|    * @typedef {Request<{}, {}, LibraryReorderObj[], {}> & RequestUserObject} LibraryReorderRequest
 | |
|    *
 | |
|    * @param {LibraryReorderRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async reorder(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user}" attempted to reorder libraries`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const libraries = await Database.libraryModel.getAllWithFolders()
 | |
| 
 | |
|     const orderdata = req.body
 | |
|     if (!Array.isArray(orderdata) || orderdata.some((o) => typeof o?.id !== 'string' || typeof o?.newOrder !== 'number')) {
 | |
|       return res.status(400).send('Invalid request. Request body must be an array of objects')
 | |
|     }
 | |
| 
 | |
|     let hasUpdates = false
 | |
|     for (let i = 0; i < orderdata.length; i++) {
 | |
|       const library = libraries.find((lib) => lib.id === orderdata[i].id)
 | |
|       if (!library) {
 | |
|         Logger.error(`[LibraryController] Invalid library not found in reorder ${orderdata[i].id}`)
 | |
|         return res.status(400).send(`Library not found with id ${orderdata[i].id}`)
 | |
|       }
 | |
|       if (library.displayOrder === orderdata[i].newOrder) continue
 | |
|       library.displayOrder = orderdata[i].newOrder
 | |
|       await library.save()
 | |
|       hasUpdates = true
 | |
|     }
 | |
| 
 | |
|     if (hasUpdates) {
 | |
|       libraries.sort((a, b) => a.displayOrder - b.displayOrder)
 | |
|       Logger.debug(`[LibraryController] Updated library display orders`)
 | |
|     } else {
 | |
|       Logger.debug(`[LibraryController] Library orders were up to date`)
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       libraries: libraries.map((lib) => lib.toOldJSON())
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/search
 | |
|    * Search library items with query
 | |
|    *
 | |
|    * ?q=search
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async search(req, res) {
 | |
|     if (!req.query.q || typeof req.query.q !== 'string') {
 | |
|       return res.status(400).send('Invalid request. Query param "q" must be a string')
 | |
|     }
 | |
| 
 | |
|     const limit = req.query.limit || 12
 | |
|     const query = req.query.q.trim()
 | |
| 
 | |
|     const matches = await libraryItemFilters.search(req.user, req.library, query, limit)
 | |
|     res.json(matches)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/stats
 | |
|    * Get stats for library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async stats(req, res) {
 | |
|     const stats = {
 | |
|       largestItems: await libraryItemFilters.getLargestItems(req.library.id, 10)
 | |
|     }
 | |
| 
 | |
|     if (req.library.mediaType === 'book') {
 | |
|       const authors = await authorFilters.getAuthorsWithCount(req.library.id, 10)
 | |
|       const genres = await libraryItemsBookFilters.getGenresWithCount(req.library.id)
 | |
|       const bookStats = await libraryItemsBookFilters.getBookLibraryStats(req.library.id)
 | |
|       const longestBooks = await libraryItemsBookFilters.getLongestBooks(req.library.id, 10)
 | |
| 
 | |
|       stats.totalAuthors = await authorFilters.getAuthorsTotalCount(req.library.id)
 | |
|       stats.authorsWithCount = authors
 | |
|       stats.totalGenres = genres.length
 | |
|       stats.genresWithCount = genres
 | |
|       stats.totalItems = bookStats.totalItems
 | |
|       stats.longestItems = longestBooks
 | |
|       stats.totalSize = bookStats.totalSize
 | |
|       stats.totalDuration = bookStats.totalDuration
 | |
|       stats.numAudioTracks = bookStats.numAudioFiles
 | |
|     } else {
 | |
|       const genres = await libraryItemsPodcastFilters.getGenresWithCount(req.library.id)
 | |
|       const podcastStats = await libraryItemsPodcastFilters.getPodcastLibraryStats(req.library.id)
 | |
|       const longestPodcasts = await libraryItemsPodcastFilters.getLongestPodcasts(req.library.id, 10)
 | |
| 
 | |
|       stats.totalGenres = genres.length
 | |
|       stats.genresWithCount = genres
 | |
|       stats.totalItems = podcastStats.totalItems
 | |
|       stats.longestItems = longestPodcasts
 | |
|       stats.totalSize = podcastStats.totalSize
 | |
|       stats.totalDuration = podcastStats.totalDuration
 | |
|       stats.numAudioTracks = podcastStats.numAudioFiles
 | |
|     }
 | |
|     res.json(stats)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/authors
 | |
|    * Get authors for library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getAuthors(req, res) {
 | |
|     const isPaginated = req.query.limit && !isNaN(req.query.limit) && !isNaN(req.query.page)
 | |
| 
 | |
|     const payload = {
 | |
|       results: [],
 | |
|       total: 0,
 | |
|       limit: isPaginated ? Number(req.query.limit) : 0,
 | |
|       page: isPaginated ? Number(req.query.page) : 0,
 | |
|       sortBy: req.query.sort,
 | |
|       sortDesc: req.query.desc === '1',
 | |
|       filterBy: req.query.filter,
 | |
|       minified: req.query.minified === '1',
 | |
|       include: req.query.include
 | |
|     }
 | |
| 
 | |
|     // create order, limit and offset for pagination
 | |
|     let offset = isPaginated ? payload.page * payload.limit : undefined
 | |
|     let limit = isPaginated ? payload.limit : undefined
 | |
|     let order = undefined
 | |
|     const direction = payload.sortDesc ? 'DESC' : 'ASC'
 | |
|     if (payload.sortBy === 'name') {
 | |
|       order = [[Sequelize.literal('name COLLATE NOCASE'), direction]]
 | |
|     } else if (payload.sortBy === 'lastFirst') {
 | |
|       order = [[Sequelize.literal('lastFirst COLLATE NOCASE'), direction]]
 | |
|     } else if (payload.sortBy === 'addedAt') {
 | |
|       order = [['createdAt', direction]]
 | |
|     } else if (payload.sortBy === 'updatedAt') {
 | |
|       order = [['updatedAt', direction]]
 | |
|     } else if (payload.sortBy === 'numBooks') {
 | |
|       offset = undefined
 | |
|       limit = undefined
 | |
|     }
 | |
| 
 | |
|     const { bookWhere, replacements } = libraryItemsBookFilters.getUserPermissionBookWhereQuery(req.user)
 | |
|     const { rows: authors, count } = await Database.authorModel.findAndCountAll({
 | |
|       where: {
 | |
|         libraryId: req.library.id
 | |
|       },
 | |
|       replacements,
 | |
|       include: {
 | |
|         model: Database.bookModel,
 | |
|         attributes: ['id', 'tags', 'explicit'],
 | |
|         where: bookWhere,
 | |
|         required: !req.user.isAdminOrUp, // Only show authors with 0 books for admin users or up
 | |
|         through: {
 | |
|           attributes: []
 | |
|         }
 | |
|       },
 | |
|       order: order,
 | |
|       limit: limit,
 | |
|       offset: offset,
 | |
|       distinct: true
 | |
|     })
 | |
| 
 | |
|     let oldAuthors = []
 | |
| 
 | |
|     for (const author of authors) {
 | |
|       const oldAuthor = author.toOldJSONExpanded(author.books.length)
 | |
|       oldAuthor.lastFirst = author.lastFirst
 | |
|       oldAuthors.push(oldAuthor)
 | |
|     }
 | |
| 
 | |
|     // numBooks sort is handled post-query
 | |
|     if (payload.sortBy === 'numBooks') {
 | |
|       oldAuthors.sort((a, b) => (payload.sortDesc ? b.numBooks - a.numBooks : a.numBooks - b.numBooks))
 | |
|       if (isPaginated) {
 | |
|         const startIndex = payload.page * payload.limit
 | |
|         const endIndex = startIndex + payload.limit
 | |
|         oldAuthors = oldAuthors.slice(startIndex, endIndex)
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     payload.results = oldAuthors
 | |
|     if (isPaginated) {
 | |
|       payload.total = count
 | |
|       res.json(payload)
 | |
|     } else {
 | |
|       res.json({
 | |
|         authors: payload.results
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/narrators
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getNarrators(req, res) {
 | |
|     // Get all books with narrators
 | |
|     const booksWithNarrators = await Database.bookModel.findAll({
 | |
|       where: Sequelize.where(Sequelize.fn('json_array_length', Sequelize.col('narrators')), {
 | |
|         [Sequelize.Op.gt]: 0
 | |
|       }),
 | |
|       include: {
 | |
|         model: Database.libraryItemModel,
 | |
|         attributes: ['id', 'libraryId'],
 | |
|         where: {
 | |
|           libraryId: req.library.id
 | |
|         }
 | |
|       },
 | |
|       attributes: ['id', 'narrators']
 | |
|     })
 | |
| 
 | |
|     const narrators = {}
 | |
|     for (const book of booksWithNarrators) {
 | |
|       book.narrators.forEach((n) => {
 | |
|         if (typeof n !== 'string') {
 | |
|           Logger.error(`[LibraryController] getNarrators: Invalid narrator "${n}" on book "${book.title}"`)
 | |
|         } else if (!narrators[n]) {
 | |
|           narrators[n] = {
 | |
|             id: encodeURIComponent(Buffer.from(n).toString('base64')),
 | |
|             name: n,
 | |
|             numBooks: 1
 | |
|           }
 | |
|         } else {
 | |
|           narrators[n].numBooks++
 | |
|         }
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       narrators: naturalSort(Object.values(narrators)).asc((n) => n.name)
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * PATCH: /api/libraries/:id/narrators/:narratorId
 | |
|    * Update narrator name
 | |
|    * :narratorId is base64 encoded name
 | |
|    * req.body { name }
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async updateNarrator(req, res) {
 | |
|     if (!req.user.canUpdate) {
 | |
|       Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to update narrator`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const narratorName = libraryFilters.decode(req.params.narratorId)
 | |
|     const updatedName = req.body.name
 | |
|     if (!updatedName) {
 | |
|       return res.status(400).send('Invalid request payload. Name not specified.')
 | |
|     }
 | |
| 
 | |
|     // Update filter data
 | |
|     Database.replaceNarratorInFilterData(narratorName, updatedName)
 | |
| 
 | |
|     const itemsUpdated = []
 | |
| 
 | |
|     const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
 | |
| 
 | |
|     for (const libraryItem of itemsWithNarrator) {
 | |
|       libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
 | |
|       if (!libraryItem.media.narrators.includes(updatedName)) {
 | |
|         libraryItem.media.narrators.push(updatedName)
 | |
|       }
 | |
|       await libraryItem.media.update({
 | |
|         narrators: libraryItem.media.narrators
 | |
|       })
 | |
| 
 | |
|       itemsUpdated.push(libraryItem)
 | |
|     }
 | |
| 
 | |
|     if (itemsUpdated.length) {
 | |
|       SocketAuthority.emitter(
 | |
|         'items_updated',
 | |
|         itemsUpdated.map((li) => li.toOldJSONExpanded())
 | |
|       )
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       updated: itemsUpdated.length
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * DELETE: /api/libraries/:id/narrators/:narratorId
 | |
|    * Remove narrator
 | |
|    * :narratorId is base64 encoded name
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async removeNarrator(req, res) {
 | |
|     if (!req.user.canUpdate) {
 | |
|       Logger.error(`[LibraryController] Unauthorized user "${req.user.username}" attempted to remove narrator`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const narratorName = libraryFilters.decode(req.params.narratorId)
 | |
| 
 | |
|     // Update filter data
 | |
|     Database.removeNarratorFromFilterData(narratorName)
 | |
| 
 | |
|     const itemsUpdated = []
 | |
| 
 | |
|     const itemsWithNarrator = await libraryItemFilters.getAllLibraryItemsWithNarrators([narratorName])
 | |
| 
 | |
|     for (const libraryItem of itemsWithNarrator) {
 | |
|       libraryItem.media.narrators = libraryItem.media.narrators.filter((n) => n !== narratorName)
 | |
|       await libraryItem.media.update({
 | |
|         narrators: libraryItem.media.narrators
 | |
|       })
 | |
| 
 | |
|       itemsUpdated.push(libraryItem)
 | |
|     }
 | |
| 
 | |
|     if (itemsUpdated.length) {
 | |
|       SocketAuthority.emitter(
 | |
|         'items_updated',
 | |
|         itemsUpdated.map((li) => li.toOldJSONExpanded())
 | |
|       )
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       updated: itemsUpdated.length
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/matchall
 | |
|    * Quick match all library items. Book libraries only.
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async matchAll(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-root user "${req.user.username}" attempted to match library items`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
|     Scanner.matchLibraryItems(this, req.library)
 | |
|     res.sendStatus(200)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * POST: /api/libraries/:id/scan
 | |
|    * Optional query:
 | |
|    * ?force=1
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async scan(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to scan library`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
|     res.sendStatus(200)
 | |
| 
 | |
|     const forceRescan = req.query.force === '1'
 | |
|     await LibraryScanner.scan(req.library, forceRescan)
 | |
| 
 | |
|     await Database.resetLibraryIssuesFilterData(req.library.id)
 | |
|     Logger.info('[LibraryController] Scan complete')
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/recent-episodes
 | |
|    * Used for latest page
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getRecentEpisodes(req, res) {
 | |
|     if (req.library.mediaType !== 'podcast') {
 | |
|       return res.sendStatus(404)
 | |
|     }
 | |
| 
 | |
|     const payload = {
 | |
|       episodes: [],
 | |
|       limit: req.query.limit || 0,
 | |
|       page: req.query.page || 0
 | |
|     }
 | |
| 
 | |
|     const offset = payload.page * payload.limit
 | |
|     payload.episodes = await libraryItemsPodcastFilters.getRecentEpisodes(req.user, req.library, payload.limit, offset)
 | |
|     res.json(payload)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/opml
 | |
|    * Get OPML file for a podcast library
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getOPMLFile(req, res) {
 | |
|     const userPermissionPodcastWhere = libraryItemsPodcastFilters.getUserPermissionPodcastWhereQuery(req.user)
 | |
|     const podcasts = await Database.podcastModel.findAll({
 | |
|       attributes: ['id', 'feedURL', 'title', 'description', 'itunesPageURL', 'language'],
 | |
|       where: userPermissionPodcastWhere.podcastWhere,
 | |
|       replacements: userPermissionPodcastWhere.replacements,
 | |
|       include: {
 | |
|         model: Database.libraryItemModel,
 | |
|         attributes: ['id', 'libraryId'],
 | |
|         where: {
 | |
|           libraryId: req.library.id
 | |
|         }
 | |
|       }
 | |
|     })
 | |
| 
 | |
|     const opmlText = this.podcastManager.generateOPMLFileText(podcasts)
 | |
|     res.type('application/xml')
 | |
|     res.send(opmlText)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * POST: /api/libraries/:id/remove-metadata
 | |
|    * Remove all metadata.json or metadata.abs files in library item folders
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async removeAllMetadataFiles(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to remove all metadata files`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const fileExt = req.query.ext === 'abs' ? 'abs' : 'json'
 | |
|     const metadataFilename = `metadata.${fileExt}`
 | |
|     const libraryItemsWithMetadata = await Database.libraryItemModel.findAll({
 | |
|       attributes: ['id', 'libraryFiles'],
 | |
|       where: [
 | |
|         {
 | |
|           libraryId: req.library.id
 | |
|         },
 | |
|         Sequelize.where(Sequelize.literal(`(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(libraryFiles) AND json_extract(json_each.value, "$.metadata.filename") = "${metadataFilename}")`), {
 | |
|           [Sequelize.Op.gte]: 1
 | |
|         })
 | |
|       ]
 | |
|     })
 | |
|     if (!libraryItemsWithMetadata.length) {
 | |
|       Logger.info(`[LibraryController] No ${metadataFilename} files found to remove`)
 | |
|       return res.json({
 | |
|         found: 0
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     Logger.info(`[LibraryController] Found ${libraryItemsWithMetadata.length} ${metadataFilename} files to remove`)
 | |
| 
 | |
|     let numRemoved = 0
 | |
|     for (const libraryItem of libraryItemsWithMetadata) {
 | |
|       const metadataFilepath = libraryItem.libraryFiles.find((lf) => lf.metadata.filename === metadataFilename)?.metadata.path
 | |
|       if (!metadataFilepath) continue
 | |
|       Logger.debug(`[LibraryController] Removing file "${metadataFilepath}"`)
 | |
|       if (await fileUtils.removeFile(metadataFilepath)) {
 | |
|         numRemoved++
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     res.json({
 | |
|       found: libraryItemsWithMetadata.length,
 | |
|       removed: numRemoved
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * GET: /api/libraries/:id/podcast-titles
 | |
|    *
 | |
|    * Get podcast titles with itunesId and libraryItemId for library
 | |
|    * Used on the podcast add page in order to check if a podcast is already in the library and redirect to it
 | |
|    *
 | |
|    * @param {LibraryControllerRequest} req
 | |
|    * @param {Response} res
 | |
|    */
 | |
|   async getPodcastTitles(req, res) {
 | |
|     if (!req.user.isAdminOrUp) {
 | |
|       Logger.error(`[LibraryController] Non-admin user "${req.user.username}" attempted to get podcast titles`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const podcasts = await Database.podcastModel.findAll({
 | |
|       attributes: ['id', 'title', 'itunesId'],
 | |
|       include: {
 | |
|         model: Database.libraryItemModel,
 | |
|         attributes: ['id', 'libraryId'],
 | |
|         where: {
 | |
|           libraryId: req.library.id
 | |
|         }
 | |
|       }
 | |
|     })
 | |
| 
 | |
|     res.json({
 | |
|       podcasts: podcasts.map((p) => {
 | |
|         return {
 | |
|           title: p.title,
 | |
|           itunesId: p.itunesId,
 | |
|           libraryItemId: p.libraryItem.id,
 | |
|           libraryId: p.libraryItem.libraryId
 | |
|         }
 | |
|       })
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {RequestWithUser} req
 | |
|    * @param {Response} res
 | |
|    * @param {NextFunction} next
 | |
|    */
 | |
|   async middleware(req, res, next) {
 | |
|     if (!req.user.checkCanAccessLibrary(req.params.id)) {
 | |
|       Logger.warn(`[LibraryController] Library ${req.params.id} not accessible to user ${req.user.username}`)
 | |
|       return res.sendStatus(403)
 | |
|     }
 | |
| 
 | |
|     const library = await Database.libraryModel.findByIdWithFolders(req.params.id)
 | |
|     if (!library) {
 | |
|       return res.status(404).send('Library not found')
 | |
|     }
 | |
|     req.library = library
 | |
| 
 | |
|     // Ensure pagination query params are positive integers
 | |
|     for (const queryKey of ['limit', 'page']) {
 | |
|       if (req.query[queryKey] !== undefined) {
 | |
|         req.query[queryKey] = !isNaN(req.query[queryKey]) ? Number(req.query[queryKey]) : 0
 | |
|         if (!Number.isInteger(req.query[queryKey]) || req.query[queryKey] < 0) {
 | |
|           return res.status(400).send(`Invalid request. ${queryKey} must be a positive integer`)
 | |
|         }
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     next()
 | |
|   }
 | |
| }
 | |
| module.exports = new LibraryController()
 |