mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Merge pull request #3670 from advplyr/fix_remove_authors_no_books
Fix:Remove authors with no books when a books is removed #3668
This commit is contained in:
		
						commit
						c03f18b90a
					
				@ -400,19 +400,48 @@ class LibraryController {
 | 
			
		||||
                  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.mediaType, libraryItem.id, mediaItemIds)
 | 
			
		||||
            await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          if (authorIds.length) {
 | 
			
		||||
            await this.checkRemoveAuthorsWithNoBooks(authorIds)
 | 
			
		||||
          }
 | 
			
		||||
          if (seriesIds.length) {
 | 
			
		||||
            await this.checkRemoveEmptySeries(seriesIds)
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          // Remove folder
 | 
			
		||||
@ -501,7 +530,7 @@ class LibraryController {
 | 
			
		||||
        mediaItemIds.push(libraryItem.mediaId)
 | 
			
		||||
      }
 | 
			
		||||
      Logger.info(`[LibraryController] Removing library item "${libraryItem.id}" from library "${req.library.name}"`)
 | 
			
		||||
      await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
 | 
			
		||||
      await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Set PlaybackSessions libraryId to null
 | 
			
		||||
@ -580,6 +609,8 @@ class LibraryController {
 | 
			
		||||
   * DELETE: /api/libraries/:id/issues
 | 
			
		||||
   * Remove all library items missing or invalid
 | 
			
		||||
   *
 | 
			
		||||
   * @this {import('../routers/ApiRouter')}
 | 
			
		||||
   *
 | 
			
		||||
   * @param {LibraryControllerRequest} req
 | 
			
		||||
   * @param {Response} res
 | 
			
		||||
   */
 | 
			
		||||
@ -605,6 +636,20 @@ class LibraryController {
 | 
			
		||||
            model: Database.podcastEpisodeModel,
 | 
			
		||||
            attributes: ['id']
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.bookModel,
 | 
			
		||||
          attributes: ['id'],
 | 
			
		||||
          include: [
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.bookAuthorModel,
 | 
			
		||||
              attributes: ['authorId']
 | 
			
		||||
            },
 | 
			
		||||
            {
 | 
			
		||||
              model: Database.bookSeriesModel,
 | 
			
		||||
              attributes: ['seriesId']
 | 
			
		||||
            }
 | 
			
		||||
          ]
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
    })
 | 
			
		||||
@ -615,15 +660,30 @@ class LibraryController {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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.mediaType, libraryItem.id, mediaItemIds)
 | 
			
		||||
      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
 | 
			
		||||
 | 
			
		||||
@ -96,6 +96,8 @@ class LibraryItemController {
 | 
			
		||||
   * Optional query params:
 | 
			
		||||
   * ?hard=1
 | 
			
		||||
   *
 | 
			
		||||
   * @this {import('../routers/ApiRouter')}
 | 
			
		||||
   *
 | 
			
		||||
   * @param {RequestWithUser} req
 | 
			
		||||
   * @param {Response} res
 | 
			
		||||
   */
 | 
			
		||||
@ -103,14 +105,36 @@ class LibraryItemController {
 | 
			
		||||
    const hardDelete = req.query.hard == 1 // Delete from file system
 | 
			
		||||
    const libraryItemPath = req.libraryItem.path
 | 
			
		||||
 | 
			
		||||
    const mediaItemIds = req.libraryItem.mediaType === 'podcast' ? req.libraryItem.media.episodes.map((ep) => ep.id) : [req.libraryItem.media.id]
 | 
			
		||||
    await this.handleDeleteLibraryItem(req.libraryItem.mediaType, req.libraryItem.id, mediaItemIds)
 | 
			
		||||
    const mediaItemIds = []
 | 
			
		||||
    const authorIds = []
 | 
			
		||||
    const seriesIds = []
 | 
			
		||||
    if (req.libraryItem.isPodcast) {
 | 
			
		||||
      mediaItemIds.push(...req.libraryItem.media.episodes.map((ep) => ep.id))
 | 
			
		||||
    } else {
 | 
			
		||||
      mediaItemIds.push(req.libraryItem.media.id)
 | 
			
		||||
      if (req.libraryItem.media.metadata.authors?.length) {
 | 
			
		||||
        authorIds.push(...req.libraryItem.media.metadata.authors.map((au) => au.id))
 | 
			
		||||
      }
 | 
			
		||||
      if (req.libraryItem.media.metadata.series?.length) {
 | 
			
		||||
        seriesIds.push(...req.libraryItem.media.metadata.series.map((se) => se.id))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await this.handleDeleteLibraryItem(req.libraryItem.id, mediaItemIds)
 | 
			
		||||
    if (hardDelete) {
 | 
			
		||||
      Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
 | 
			
		||||
      await fs.remove(libraryItemPath).catch((error) => {
 | 
			
		||||
        Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
 | 
			
		||||
      })
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (authorIds.length) {
 | 
			
		||||
      await this.checkRemoveAuthorsWithNoBooks(authorIds)
 | 
			
		||||
    }
 | 
			
		||||
    if (seriesIds.length) {
 | 
			
		||||
      await this.checkRemoveEmptySeries(seriesIds)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(req.libraryItem.libraryId)
 | 
			
		||||
    res.sendStatus(200)
 | 
			
		||||
  }
 | 
			
		||||
@ -212,15 +236,6 @@ class LibraryItemController {
 | 
			
		||||
    if (hasUpdates) {
 | 
			
		||||
      libraryItem.updatedAt = Date.now()
 | 
			
		||||
 | 
			
		||||
      if (seriesRemoved.length) {
 | 
			
		||||
        // Check remove empty series
 | 
			
		||||
        Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
 | 
			
		||||
        await this.checkRemoveEmptySeries(
 | 
			
		||||
          libraryItem.media.id,
 | 
			
		||||
          seriesRemoved.map((se) => se.id)
 | 
			
		||||
        )
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (isPodcastAutoDownloadUpdated) {
 | 
			
		||||
        this.cronManager.checkUpdatePodcastCron(libraryItem)
 | 
			
		||||
      }
 | 
			
		||||
@ -232,10 +247,12 @@ class LibraryItemController {
 | 
			
		||||
      if (authorsRemoved.length) {
 | 
			
		||||
        // Check remove empty authors
 | 
			
		||||
        Logger.debug(`[LibraryItemController] Authors were removed from book. Check if authors are now empty.`)
 | 
			
		||||
        await this.checkRemoveAuthorsWithNoBooks(
 | 
			
		||||
          libraryItem.libraryId,
 | 
			
		||||
          authorsRemoved.map((au) => au.id)
 | 
			
		||||
        )
 | 
			
		||||
        await this.checkRemoveAuthorsWithNoBooks(authorsRemoved.map((au) => au.id))
 | 
			
		||||
      }
 | 
			
		||||
      if (seriesRemoved.length) {
 | 
			
		||||
        // Check remove empty series
 | 
			
		||||
        Logger.debug(`[LibraryItemController] Series were removed from book. Check if series are now empty.`)
 | 
			
		||||
        await this.checkRemoveEmptySeries(seriesRemoved.map((se) => se.id))
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    res.json({
 | 
			
		||||
@ -450,6 +467,8 @@ class LibraryItemController {
 | 
			
		||||
   * Optional query params:
 | 
			
		||||
   * ?hard=1
 | 
			
		||||
   *
 | 
			
		||||
   * @this {import('../routers/ApiRouter')}
 | 
			
		||||
   *
 | 
			
		||||
   * @param {RequestWithUser} req
 | 
			
		||||
   * @param {Response} res
 | 
			
		||||
   */
 | 
			
		||||
@ -477,14 +496,33 @@ class LibraryItemController {
 | 
			
		||||
    for (const libraryItem of itemsToDelete) {
 | 
			
		||||
      const libraryItemPath = libraryItem.path
 | 
			
		||||
      Logger.info(`[LibraryItemController] (${hardDelete ? 'Hard' : 'Soft'}) deleting Library Item "${libraryItem.media.metadata.title}" with id "${libraryItem.id}"`)
 | 
			
		||||
      const mediaItemIds = libraryItem.mediaType === 'podcast' ? libraryItem.media.episodes.map((ep) => ep.id) : [libraryItem.media.id]
 | 
			
		||||
      await this.handleDeleteLibraryItem(libraryItem.mediaType, libraryItem.id, mediaItemIds)
 | 
			
		||||
      const mediaItemIds = []
 | 
			
		||||
      const seriesIds = []
 | 
			
		||||
      const authorIds = []
 | 
			
		||||
      if (libraryItem.isPodcast) {
 | 
			
		||||
        mediaItemIds.push(...libraryItem.media.episodes.map((ep) => ep.id))
 | 
			
		||||
      } else {
 | 
			
		||||
        mediaItemIds.push(libraryItem.media.id)
 | 
			
		||||
        if (libraryItem.media.metadata.series?.length) {
 | 
			
		||||
          seriesIds.push(...libraryItem.media.metadata.series.map((se) => se.id))
 | 
			
		||||
        }
 | 
			
		||||
        if (libraryItem.media.metadata.authors?.length) {
 | 
			
		||||
          authorIds.push(...libraryItem.media.metadata.authors.map((au) => au.id))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      await this.handleDeleteLibraryItem(libraryItem.id, mediaItemIds)
 | 
			
		||||
      if (hardDelete) {
 | 
			
		||||
        Logger.info(`[LibraryItemController] Deleting library item from file system at "${libraryItemPath}"`)
 | 
			
		||||
        await fs.remove(libraryItemPath).catch((error) => {
 | 
			
		||||
          Logger.error(`[LibraryItemController] Failed to delete library item from file system at "${libraryItemPath}"`, error)
 | 
			
		||||
        })
 | 
			
		||||
      }
 | 
			
		||||
      if (seriesIds.length) {
 | 
			
		||||
        await this.checkRemoveEmptySeries(seriesIds)
 | 
			
		||||
      }
 | 
			
		||||
      if (authorIds.length) {
 | 
			
		||||
        await this.checkRemoveAuthorsWithNoBooks(authorIds)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Database.resetLibraryIssuesFilterData(libraryId)
 | 
			
		||||
@ -494,48 +532,74 @@ class LibraryItemController {
 | 
			
		||||
  /**
 | 
			
		||||
   * POST: /api/items/batch/update
 | 
			
		||||
   *
 | 
			
		||||
   * @this {import('../routers/ApiRouter')}
 | 
			
		||||
   *
 | 
			
		||||
   * @param {RequestWithUser} req
 | 
			
		||||
   * @param {Response} res
 | 
			
		||||
   */
 | 
			
		||||
  async batchUpdate(req, res) {
 | 
			
		||||
    const updatePayloads = req.body
 | 
			
		||||
    if (!updatePayloads?.length) {
 | 
			
		||||
      return res.sendStatus(500)
 | 
			
		||||
    if (!Array.isArray(updatePayloads) || !updatePayloads.length) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] Batch update failed. Invalid payload`)
 | 
			
		||||
      return res.sendStatus(400)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Ensure that each update payload has a unique library item id
 | 
			
		||||
    const libraryItemIds = [...new Set(updatePayloads.map((up) => up?.id).filter((id) => id))]
 | 
			
		||||
    if (!libraryItemIds.length || libraryItemIds.length !== updatePayloads.length) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] Batch update failed. Each update payload must have a unique library item id`)
 | 
			
		||||
      return res.sendStatus(400)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Get all library items to update
 | 
			
		||||
    const libraryItems = await Database.libraryItemModel.getAllOldLibraryItems({
 | 
			
		||||
      id: libraryItemIds
 | 
			
		||||
    })
 | 
			
		||||
    if (updatePayloads.length !== libraryItems.length) {
 | 
			
		||||
      Logger.error(`[LibraryItemController] Batch update failed. Not all library items found`)
 | 
			
		||||
      return res.sendStatus(404)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let itemsUpdated = 0
 | 
			
		||||
 | 
			
		||||
    const seriesIdsRemoved = []
 | 
			
		||||
    const authorIdsRemoved = []
 | 
			
		||||
 | 
			
		||||
    for (const updatePayload of updatePayloads) {
 | 
			
		||||
      const mediaPayload = updatePayload.mediaPayload
 | 
			
		||||
      const libraryItem = await Database.libraryItemModel.getOldById(updatePayload.id)
 | 
			
		||||
      if (!libraryItem) return null
 | 
			
		||||
      const libraryItem = libraryItems.find((li) => li.id === updatePayload.id)
 | 
			
		||||
 | 
			
		||||
      await this.createAuthorsAndSeriesForItemUpdate(mediaPayload, libraryItem.libraryId)
 | 
			
		||||
 | 
			
		||||
      let seriesRemoved = []
 | 
			
		||||
      if (libraryItem.isBook && mediaPayload.metadata?.series) {
 | 
			
		||||
        const seriesIdsInUpdate = (mediaPayload.metadata?.series || []).map((se) => se.id)
 | 
			
		||||
        seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
 | 
			
		||||
      if (libraryItem.isBook) {
 | 
			
		||||
        if (Array.isArray(mediaPayload.metadata?.series)) {
 | 
			
		||||
          const seriesIdsInUpdate = mediaPayload.metadata.series.map((se) => se.id)
 | 
			
		||||
          const seriesRemoved = libraryItem.media.metadata.series.filter((se) => !seriesIdsInUpdate.includes(se.id))
 | 
			
		||||
          seriesIdsRemoved.push(...seriesRemoved.map((se) => se.id))
 | 
			
		||||
        }
 | 
			
		||||
        if (Array.isArray(mediaPayload.metadata?.authors)) {
 | 
			
		||||
          const authorIdsInUpdate = mediaPayload.metadata.authors.map((au) => au.id)
 | 
			
		||||
          const authorsRemoved = libraryItem.media.metadata.authors.filter((au) => !authorIdsInUpdate.includes(au.id))
 | 
			
		||||
          authorIdsRemoved.push(...authorsRemoved.map((au) => au.id))
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (libraryItem.media.update(mediaPayload)) {
 | 
			
		||||
        Logger.debug(`[LibraryItemController] Updated library item media ${libraryItem.media.metadata.title}`)
 | 
			
		||||
 | 
			
		||||
        if (seriesRemoved.length) {
 | 
			
		||||
          // Check remove empty series
 | 
			
		||||
          Logger.debug(`[LibraryItemController] Series was removed from book. Check if series is now empty.`)
 | 
			
		||||
          await this.checkRemoveEmptySeries(
 | 
			
		||||
            libraryItem.media.id,
 | 
			
		||||
            seriesRemoved.map((se) => se.id)
 | 
			
		||||
          )
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await Database.updateLibraryItem(libraryItem)
 | 
			
		||||
        SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
 | 
			
		||||
        itemsUpdated++
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (seriesIdsRemoved.length) {
 | 
			
		||||
      await this.checkRemoveEmptySeries(seriesIdsRemoved)
 | 
			
		||||
    }
 | 
			
		||||
    if (authorIdsRemoved.length) {
 | 
			
		||||
      await this.checkRemoveAuthorsWithNoBooks(authorIdsRemoved)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    res.json({
 | 
			
		||||
      success: true,
 | 
			
		||||
      updates: itemsUpdated
 | 
			
		||||
 | 
			
		||||
@ -86,6 +86,7 @@ class CacheManager {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async purgeEntityCache(entityId, cachePath) {
 | 
			
		||||
    if (!entityId || !cachePath) return []
 | 
			
		||||
    return Promise.all(
 | 
			
		||||
      (await fs.readdir(cachePath)).reduce((promises, file) => {
 | 
			
		||||
        if (file.startsWith(entityId)) {
 | 
			
		||||
 | 
			
		||||
@ -262,7 +262,7 @@ class LibraryItem {
 | 
			
		||||
   * @returns {Promise<LibraryFile>} null if not saved
 | 
			
		||||
   */
 | 
			
		||||
  async saveMetadata() {
 | 
			
		||||
    if (this.isSavingMetadata) return null
 | 
			
		||||
    if (this.isSavingMetadata || !global.MetadataPath) return null
 | 
			
		||||
 | 
			
		||||
    this.isSavingMetadata = true
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -348,11 +348,10 @@ class ApiRouter {
 | 
			
		||||
  //
 | 
			
		||||
  /**
 | 
			
		||||
   * Remove library item and associated entities
 | 
			
		||||
   * @param {string} mediaType
 | 
			
		||||
   * @param {string} libraryItemId
 | 
			
		||||
   * @param {string[]} mediaItemIds array of bookId or podcastEpisodeId
 | 
			
		||||
   */
 | 
			
		||||
  async handleDeleteLibraryItem(mediaType, libraryItemId, mediaItemIds) {
 | 
			
		||||
  async handleDeleteLibraryItem(libraryItemId, mediaItemIds) {
 | 
			
		||||
    const numProgressRemoved = await Database.mediaProgressModel.destroy({
 | 
			
		||||
      where: {
 | 
			
		||||
        mediaItemId: mediaItemIds
 | 
			
		||||
@ -362,29 +361,6 @@ class ApiRouter {
 | 
			
		||||
      Logger.info(`[ApiRouter] Removed ${numProgressRemoved} media progress entries for library item "${libraryItemId}"`)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // TODO: Remove open sessions for library item
 | 
			
		||||
 | 
			
		||||
    // Remove series if empty
 | 
			
		||||
    if (mediaType === 'book') {
 | 
			
		||||
      // TODO: update filter data
 | 
			
		||||
      const bookSeries = await Database.bookSeriesModel.findAll({
 | 
			
		||||
        where: {
 | 
			
		||||
          bookId: mediaItemIds[0]
 | 
			
		||||
        },
 | 
			
		||||
        include: {
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.bookModel
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      for (const bs of bookSeries) {
 | 
			
		||||
        if (bs.series.books.length === 1) {
 | 
			
		||||
          await this.removeEmptySeries(bs.series)
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // remove item from playlists
 | 
			
		||||
    const playlistsWithItem = await Database.playlistModel.getPlaylistsForMediaItemIds(mediaItemIds)
 | 
			
		||||
    for (const playlist of playlistsWithItem) {
 | 
			
		||||
@ -423,10 +399,13 @@ class ApiRouter {
 | 
			
		||||
    // purge cover cache
 | 
			
		||||
    await CacheManager.purgeCoverCache(libraryItemId)
 | 
			
		||||
 | 
			
		||||
    const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
 | 
			
		||||
    if (await fs.pathExists(itemMetadataPath)) {
 | 
			
		||||
      Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
 | 
			
		||||
      await fs.remove(itemMetadataPath)
 | 
			
		||||
    // Remove metadata file if in /metadata/items dir
 | 
			
		||||
    if (global.MetadataPath) {
 | 
			
		||||
      const itemMetadataPath = Path.join(global.MetadataPath, 'items', libraryItemId)
 | 
			
		||||
      if (await fs.pathExists(itemMetadataPath)) {
 | 
			
		||||
        Logger.info(`[ApiRouter] Removing item metadata at "${itemMetadataPath}"`)
 | 
			
		||||
        await fs.remove(itemMetadataPath)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    await Database.libraryItemModel.removeById(libraryItemId)
 | 
			
		||||
@ -437,32 +416,27 @@ class ApiRouter {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /**
 | 
			
		||||
   * Used when a series is removed from a book
 | 
			
		||||
   * Series is removed if it only has 1 book
 | 
			
		||||
   * After deleting book(s), remove empty series
 | 
			
		||||
   *
 | 
			
		||||
   * @param {string} bookId
 | 
			
		||||
   * @param {string[]} seriesIds
 | 
			
		||||
   */
 | 
			
		||||
  async checkRemoveEmptySeries(bookId, seriesIds) {
 | 
			
		||||
  async checkRemoveEmptySeries(seriesIds) {
 | 
			
		||||
    if (!seriesIds?.length) return
 | 
			
		||||
 | 
			
		||||
    const bookSeries = await Database.bookSeriesModel.findAll({
 | 
			
		||||
    const series = await Database.seriesModel.findAll({
 | 
			
		||||
      where: {
 | 
			
		||||
        bookId,
 | 
			
		||||
        seriesId: seriesIds
 | 
			
		||||
        id: seriesIds
 | 
			
		||||
      },
 | 
			
		||||
      include: [
 | 
			
		||||
        {
 | 
			
		||||
          model: Database.seriesModel,
 | 
			
		||||
          include: {
 | 
			
		||||
            model: Database.bookModel
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      ]
 | 
			
		||||
      attributes: ['id', 'name', 'libraryId'],
 | 
			
		||||
      include: {
 | 
			
		||||
        model: Database.bookModel,
 | 
			
		||||
        attributes: ['id']
 | 
			
		||||
      }
 | 
			
		||||
    })
 | 
			
		||||
    for (const bs of bookSeries) {
 | 
			
		||||
      if (bs.series.books.length === 1) {
 | 
			
		||||
        await this.removeEmptySeries(bs.series)
 | 
			
		||||
 | 
			
		||||
    for (const s of series) {
 | 
			
		||||
      if (!s.books.length) {
 | 
			
		||||
        await this.removeEmptySeries(s)
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -471,11 +445,10 @@ class ApiRouter {
 | 
			
		||||
   * Remove authors with no books and unset asin, description and imagePath
 | 
			
		||||
   * Note: Other implementation is in BookScanner.checkAuthorsRemovedFromBooks (can be merged)
 | 
			
		||||
   *
 | 
			
		||||
   * @param {string} libraryId
 | 
			
		||||
   * @param {string[]} authorIds
 | 
			
		||||
   * @returns {Promise<void>}
 | 
			
		||||
   */
 | 
			
		||||
  async checkRemoveAuthorsWithNoBooks(libraryId, authorIds) {
 | 
			
		||||
  async checkRemoveAuthorsWithNoBooks(authorIds) {
 | 
			
		||||
    if (!authorIds?.length) return
 | 
			
		||||
 | 
			
		||||
    const bookAuthorsToRemove = (
 | 
			
		||||
@ -495,10 +468,10 @@ class ApiRouter {
 | 
			
		||||
          },
 | 
			
		||||
          sequelize.where(sequelize.literal('(SELECT count(*) FROM bookAuthors ba WHERE ba.authorId = author.id)'), 0)
 | 
			
		||||
        ],
 | 
			
		||||
        attributes: ['id', 'name'],
 | 
			
		||||
        attributes: ['id', 'name', 'libraryId'],
 | 
			
		||||
        raw: true
 | 
			
		||||
      })
 | 
			
		||||
    ).map((au) => ({ id: au.id, name: au.name }))
 | 
			
		||||
    ).map((au) => ({ id: au.id, name: au.name, libraryId: au.libraryId }))
 | 
			
		||||
 | 
			
		||||
    if (bookAuthorsToRemove.length) {
 | 
			
		||||
      await Database.authorModel.destroy({
 | 
			
		||||
@ -506,7 +479,7 @@ class ApiRouter {
 | 
			
		||||
          id: bookAuthorsToRemove.map((au) => au.id)
 | 
			
		||||
        }
 | 
			
		||||
      })
 | 
			
		||||
      bookAuthorsToRemove.forEach(({ id, name }) => {
 | 
			
		||||
      bookAuthorsToRemove.forEach(({ id, name, libraryId }) => {
 | 
			
		||||
        Database.removeAuthorFromFilterData(libraryId, id)
 | 
			
		||||
        // TODO: Clients were expecting full author in payload but its unnecessary
 | 
			
		||||
        SocketAuthority.emitter('author_removed', { id, libraryId })
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										202
									
								
								test/server/controllers/LibraryItemController.test.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										202
									
								
								test/server/controllers/LibraryItemController.test.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,202 @@
 | 
			
		||||
const { expect } = require('chai')
 | 
			
		||||
const { Sequelize } = require('sequelize')
 | 
			
		||||
const sinon = require('sinon')
 | 
			
		||||
 | 
			
		||||
const Database = require('../../../server/Database')
 | 
			
		||||
const ApiRouter = require('../../../server/routers/ApiRouter')
 | 
			
		||||
const LibraryItemController = require('../../../server/controllers/LibraryItemController')
 | 
			
		||||
const ApiCacheManager = require('../../../server/managers/ApiCacheManager')
 | 
			
		||||
const RssFeedManager = require('../../../server/managers/RssFeedManager')
 | 
			
		||||
const Logger = require('../../../server/Logger')
 | 
			
		||||
 | 
			
		||||
describe('LibraryItemController', () => {
 | 
			
		||||
  /** @type {ApiRouter} */
 | 
			
		||||
  let apiRouter
 | 
			
		||||
 | 
			
		||||
  beforeEach(async () => {
 | 
			
		||||
    global.ServerSettings = {}
 | 
			
		||||
    Database.sequelize = new Sequelize({ dialect: 'sqlite', storage: ':memory:', logging: false })
 | 
			
		||||
    Database.sequelize.uppercaseFirst = (str) => (str ? `${str[0].toUpperCase()}${str.substr(1)}` : '')
 | 
			
		||||
    await Database.buildModels()
 | 
			
		||||
 | 
			
		||||
    apiRouter = new ApiRouter({
 | 
			
		||||
      apiCacheManager: new ApiCacheManager(),
 | 
			
		||||
      rssFeedManager: new RssFeedManager()
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    sinon.stub(Logger, 'info')
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  afterEach(async () => {
 | 
			
		||||
    sinon.restore()
 | 
			
		||||
 | 
			
		||||
    // Clear all tables
 | 
			
		||||
    await Database.sequelize.sync({ force: true })
 | 
			
		||||
  })
 | 
			
		||||
 | 
			
		||||
  describe('checkRemoveAuthorsAndSeries', () => {
 | 
			
		||||
    let libraryItem1Id
 | 
			
		||||
    let libraryItem2Id
 | 
			
		||||
    let author1Id
 | 
			
		||||
    let author2Id
 | 
			
		||||
    let author3Id
 | 
			
		||||
    let series1Id
 | 
			
		||||
    let series2Id
 | 
			
		||||
 | 
			
		||||
    beforeEach(async () => {
 | 
			
		||||
      const newLibrary = await Database.libraryModel.create({ name: 'Test Library', mediaType: 'book' })
 | 
			
		||||
      const newLibraryFolder = await Database.libraryFolderModel.create({ path: '/test', libraryId: newLibrary.id })
 | 
			
		||||
 | 
			
		||||
      const newBook = await Database.bookModel.create({ title: 'Test Book', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
 | 
			
		||||
      const newLibraryItem = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
 | 
			
		||||
      libraryItem1Id = newLibraryItem.id
 | 
			
		||||
 | 
			
		||||
      const newBook2 = await Database.bookModel.create({ title: 'Test Book 2', audioFiles: [], tags: [], narrators: [], genres: [], chapters: [] })
 | 
			
		||||
      const newLibraryItem2 = await Database.libraryItemModel.create({ libraryFiles: [], mediaId: newBook2.id, mediaType: 'book', libraryId: newLibrary.id, libraryFolderId: newLibraryFolder.id })
 | 
			
		||||
      libraryItem2Id = newLibraryItem2.id
 | 
			
		||||
 | 
			
		||||
      const newAuthor = await Database.authorModel.create({ name: 'Test Author', libraryId: newLibrary.id })
 | 
			
		||||
      author1Id = newAuthor.id
 | 
			
		||||
      const newAuthor2 = await Database.authorModel.create({ name: 'Test Author 2', libraryId: newLibrary.id })
 | 
			
		||||
      author2Id = newAuthor2.id
 | 
			
		||||
      const newAuthor3 = await Database.authorModel.create({ name: 'Test Author 3', imagePath: '/fake/path/author.png', libraryId: newLibrary.id })
 | 
			
		||||
      author3Id = newAuthor3.id
 | 
			
		||||
 | 
			
		||||
      // Book 1 has Author 1, Author 2 and Author 3
 | 
			
		||||
      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor.id })
 | 
			
		||||
      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor2.id })
 | 
			
		||||
      await Database.bookAuthorModel.create({ bookId: newBook.id, authorId: newAuthor3.id })
 | 
			
		||||
 | 
			
		||||
      // Book 2 has Author 2
 | 
			
		||||
      await Database.bookAuthorModel.create({ bookId: newBook2.id, authorId: newAuthor2.id })
 | 
			
		||||
 | 
			
		||||
      const newSeries = await Database.seriesModel.create({ name: 'Test Series', libraryId: newLibrary.id })
 | 
			
		||||
      series1Id = newSeries.id
 | 
			
		||||
      const newSeries2 = await Database.seriesModel.create({ name: 'Test Series 2', libraryId: newLibrary.id })
 | 
			
		||||
      series2Id = newSeries2.id
 | 
			
		||||
 | 
			
		||||
      // Book 1 is in Series 1 and Series 2
 | 
			
		||||
      await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries.id })
 | 
			
		||||
      await Database.bookSeriesModel.create({ bookId: newBook.id, seriesId: newSeries2.id })
 | 
			
		||||
 | 
			
		||||
      // Book 2 is in Series 2
 | 
			
		||||
      await Database.bookSeriesModel.create({ bookId: newBook2.id, seriesId: newSeries2.id })
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('should remove authors and series with no books on library item delete', async () => {
 | 
			
		||||
      const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
 | 
			
		||||
 | 
			
		||||
      const fakeReq = {
 | 
			
		||||
        query: {},
 | 
			
		||||
        libraryItem: oldLibraryItem
 | 
			
		||||
      }
 | 
			
		||||
      const fakeRes = {
 | 
			
		||||
        sendStatus: sinon.spy()
 | 
			
		||||
      }
 | 
			
		||||
      await LibraryItemController.delete.bind(apiRouter)(fakeReq, fakeRes)
 | 
			
		||||
 | 
			
		||||
      expect(fakeRes.sendStatus.calledWith(200)).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 1 should be removed because it has no books
 | 
			
		||||
      const author1Exists = await Database.authorModel.checkExistsById(author1Id)
 | 
			
		||||
      expect(author1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Author 2 should not be removed because it still has Book 2
 | 
			
		||||
      const author2Exists = await Database.authorModel.checkExistsById(author2Id)
 | 
			
		||||
      expect(author2Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 3 should not be removed because it has an image
 | 
			
		||||
      const author3Exists = await Database.authorModel.checkExistsById(author3Id)
 | 
			
		||||
      expect(author3Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Series 1 should be removed because it has no books
 | 
			
		||||
      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
 | 
			
		||||
      expect(series1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Series 2 should not be removed because it still has Book 2
 | 
			
		||||
      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
 | 
			
		||||
      expect(series2Exists).to.be.true
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('should remove authors and series with no books on library item batch delete', async () => {
 | 
			
		||||
      // Batch delete library item 1
 | 
			
		||||
      const fakeReq = {
 | 
			
		||||
        query: {},
 | 
			
		||||
        user: {
 | 
			
		||||
          canDelete: true
 | 
			
		||||
        },
 | 
			
		||||
        body: {
 | 
			
		||||
          libraryItemIds: [libraryItem1Id]
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      const fakeRes = {
 | 
			
		||||
        sendStatus: sinon.spy()
 | 
			
		||||
      }
 | 
			
		||||
      await LibraryItemController.batchDelete.bind(apiRouter)(fakeReq, fakeRes)
 | 
			
		||||
 | 
			
		||||
      expect(fakeRes.sendStatus.calledWith(200)).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 1 should be removed because it has no books
 | 
			
		||||
      const author1Exists = await Database.authorModel.checkExistsById(author1Id)
 | 
			
		||||
      expect(author1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Author 2 should not be removed because it still has Book 2
 | 
			
		||||
      const author2Exists = await Database.authorModel.checkExistsById(author2Id)
 | 
			
		||||
      expect(author2Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 3 should not be removed because it has an image
 | 
			
		||||
      const author3Exists = await Database.authorModel.checkExistsById(author3Id)
 | 
			
		||||
      expect(author3Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Series 1 should be removed because it has no books
 | 
			
		||||
      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
 | 
			
		||||
      expect(series1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Series 2 should not be removed because it still has Book 2
 | 
			
		||||
      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
 | 
			
		||||
      expect(series2Exists).to.be.true
 | 
			
		||||
    })
 | 
			
		||||
 | 
			
		||||
    it('should remove authors and series with no books on library item update media', async () => {
 | 
			
		||||
      const oldLibraryItem = await Database.libraryItemModel.getOldById(libraryItem1Id)
 | 
			
		||||
 | 
			
		||||
      // Update library item 1 remove all authors and series
 | 
			
		||||
      const fakeReq = {
 | 
			
		||||
        query: {},
 | 
			
		||||
        body: {
 | 
			
		||||
          metadata: {
 | 
			
		||||
            authors: [],
 | 
			
		||||
            series: []
 | 
			
		||||
          }
 | 
			
		||||
        },
 | 
			
		||||
        libraryItem: oldLibraryItem
 | 
			
		||||
      }
 | 
			
		||||
      const fakeRes = {
 | 
			
		||||
        json: sinon.spy()
 | 
			
		||||
      }
 | 
			
		||||
      await LibraryItemController.updateMedia.bind(apiRouter)(fakeReq, fakeRes)
 | 
			
		||||
 | 
			
		||||
      expect(fakeRes.json.calledOnce).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 1 should be removed because it has no books
 | 
			
		||||
      const author1Exists = await Database.authorModel.checkExistsById(author1Id)
 | 
			
		||||
      expect(author1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Author 2 should not be removed because it still has Book 2
 | 
			
		||||
      const author2Exists = await Database.authorModel.checkExistsById(author2Id)
 | 
			
		||||
      expect(author2Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Author 3 should not be removed because it has an image
 | 
			
		||||
      const author3Exists = await Database.authorModel.checkExistsById(author3Id)
 | 
			
		||||
      expect(author3Exists).to.be.true
 | 
			
		||||
 | 
			
		||||
      // Series 1 should be removed because it has no books
 | 
			
		||||
      const series1Exists = await Database.seriesModel.checkExistsById(series1Id)
 | 
			
		||||
      expect(series1Exists).to.be.false
 | 
			
		||||
 | 
			
		||||
      // Series 2 should not be removed because it still has Book 2
 | 
			
		||||
      const series2Exists = await Database.seriesModel.checkExistsById(series2Id)
 | 
			
		||||
      expect(series2Exists).to.be.true
 | 
			
		||||
    })
 | 
			
		||||
  })
 | 
			
		||||
})
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user