mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 18:37:00 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			1115 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1115 lines
		
	
	
		
			39 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const util = require('util')
 | |
| const Path = require('path')
 | |
| const { DataTypes, Model } = require('sequelize')
 | |
| const fsExtra = require('../libs/fsExtra')
 | |
| const Logger = require('../Logger')
 | |
| const oldLibraryItem = require('../objects/LibraryItem')
 | |
| const libraryFilters = require('../utils/queries/libraryFilters')
 | |
| const { areEquivalent } = require('../utils/index')
 | |
| const { filePathToPOSIX, getFileTimestampsWithIno } = require('../utils/fileUtils')
 | |
| const LibraryFile = require('../objects/files/LibraryFile')
 | |
| const Book = require('./Book')
 | |
| const Podcast = require('./Podcast')
 | |
| 
 | |
| /**
 | |
|  * @typedef LibraryFileObject
 | |
|  * @property {string} ino
 | |
|  * @property {boolean} isSupplementary
 | |
|  * @property {number} addedAt
 | |
|  * @property {number} updatedAt
 | |
|  * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata
 | |
|  */
 | |
| 
 | |
| /**
 | |
|  * @typedef LibraryItemExpandedProperties
 | |
|  * @property {Book.BookExpanded|Podcast.PodcastExpanded} media
 | |
|  *
 | |
|  * @typedef {LibraryItem & LibraryItemExpandedProperties} LibraryItemExpanded
 | |
|  */
 | |
| 
 | |
| class LibraryItem extends Model {
 | |
|   constructor(values, options) {
 | |
|     super(values, options)
 | |
| 
 | |
|     /** @type {string} */
 | |
|     this.id
 | |
|     /** @type {string} */
 | |
|     this.ino
 | |
|     /** @type {string} */
 | |
|     this.path
 | |
|     /** @type {string} */
 | |
|     this.relPath
 | |
|     /** @type {string} */
 | |
|     this.mediaId
 | |
|     /** @type {string} */
 | |
|     this.mediaType
 | |
|     /** @type {boolean} */
 | |
|     this.isFile
 | |
|     /** @type {boolean} */
 | |
|     this.isMissing
 | |
|     /** @type {boolean} */
 | |
|     this.isInvalid
 | |
|     /** @type {Date} */
 | |
|     this.mtime
 | |
|     /** @type {Date} */
 | |
|     this.ctime
 | |
|     /** @type {Date} */
 | |
|     this.birthtime
 | |
|     /** @type {BigInt} */
 | |
|     this.size
 | |
|     /** @type {Date} */
 | |
|     this.lastScan
 | |
|     /** @type {string} */
 | |
|     this.lastScanVersion
 | |
|     /** @type {LibraryFileObject[]} */
 | |
|     this.libraryFiles
 | |
|     /** @type {Object} */
 | |
|     this.extraData
 | |
|     /** @type {string} */
 | |
|     this.libraryId
 | |
|     /** @type {string} */
 | |
|     this.libraryFolderId
 | |
|     /** @type {Date} */
 | |
|     this.createdAt
 | |
|     /** @type {Date} */
 | |
|     this.updatedAt
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Gets library items partially expanded, not including podcast episodes
 | |
|    * @todo temporary solution
 | |
|    *
 | |
|    * @param {number} offset
 | |
|    * @param {number} limit
 | |
|    * @returns {Promise<LibraryItem[]>} LibraryItem
 | |
|    */
 | |
|   static getLibraryItemsIncrement(offset, limit, where = null) {
 | |
|     return this.findAll({
 | |
|       where,
 | |
|       include: [
 | |
|         {
 | |
|           model: this.sequelize.models.book,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.author,
 | |
|               through: {
 | |
|                 attributes: ['createdAt']
 | |
|               }
 | |
|             },
 | |
|             {
 | |
|               model: this.sequelize.models.series,
 | |
|               through: {
 | |
|                 attributes: ['sequence', 'createdAt']
 | |
|               }
 | |
|             }
 | |
|           ]
 | |
|         },
 | |
|         {
 | |
|           model: this.sequelize.models.podcast
 | |
|         }
 | |
|       ],
 | |
|       order: [
 | |
|         ['createdAt', 'ASC'],
 | |
|         // Ensure author & series stay in the same order
 | |
|         [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
 | |
|         [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
 | |
|       ],
 | |
|       offset,
 | |
|       limit
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Currently unused because this is too slow and uses too much mem
 | |
|    * @param {import('sequelize').WhereOptions} [where]
 | |
|    * @returns {Array<objects.LibraryItem>} old library items
 | |
|    */
 | |
|   static async getAllOldLibraryItems(where = null) {
 | |
|     let libraryItems = await this.findAll({
 | |
|       where,
 | |
|       include: [
 | |
|         {
 | |
|           model: this.sequelize.models.book,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.author,
 | |
|               through: {
 | |
|                 attributes: []
 | |
|               }
 | |
|             },
 | |
|             {
 | |
|               model: this.sequelize.models.series,
 | |
|               through: {
 | |
|                 attributes: ['sequence']
 | |
|               }
 | |
|             }
 | |
|           ]
 | |
|         },
 | |
|         {
 | |
|           model: this.sequelize.models.podcast,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.podcastEpisode
 | |
|             }
 | |
|           ]
 | |
|         }
 | |
|       ]
 | |
|     })
 | |
|     return libraryItems.map((ti) => this.getOldLibraryItem(ti))
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Convert an expanded LibraryItem into an old library item
 | |
|    *
 | |
|    * @param {Model<LibraryItem>} libraryItemExpanded
 | |
|    * @returns {oldLibraryItem}
 | |
|    */
 | |
|   static getOldLibraryItem(libraryItemExpanded) {
 | |
|     let media = null
 | |
|     if (libraryItemExpanded.mediaType === 'book') {
 | |
|       media = this.sequelize.models.book.getOldBook(libraryItemExpanded)
 | |
|     } else if (libraryItemExpanded.mediaType === 'podcast') {
 | |
|       media = this.sequelize.models.podcast.getOldPodcast(libraryItemExpanded)
 | |
|     }
 | |
| 
 | |
|     return new oldLibraryItem({
 | |
|       id: libraryItemExpanded.id,
 | |
|       ino: libraryItemExpanded.ino,
 | |
|       oldLibraryItemId: libraryItemExpanded.extraData?.oldLibraryItemId || null,
 | |
|       libraryId: libraryItemExpanded.libraryId,
 | |
|       folderId: libraryItemExpanded.libraryFolderId,
 | |
|       path: libraryItemExpanded.path,
 | |
|       relPath: libraryItemExpanded.relPath,
 | |
|       isFile: libraryItemExpanded.isFile,
 | |
|       mtimeMs: libraryItemExpanded.mtime?.valueOf(),
 | |
|       ctimeMs: libraryItemExpanded.ctime?.valueOf(),
 | |
|       birthtimeMs: libraryItemExpanded.birthtime?.valueOf(),
 | |
|       addedAt: libraryItemExpanded.createdAt.valueOf(),
 | |
|       updatedAt: libraryItemExpanded.updatedAt.valueOf(),
 | |
|       lastScan: libraryItemExpanded.lastScan?.valueOf(),
 | |
|       scanVersion: libraryItemExpanded.lastScanVersion,
 | |
|       isMissing: !!libraryItemExpanded.isMissing,
 | |
|       isInvalid: !!libraryItemExpanded.isInvalid,
 | |
|       mediaType: libraryItemExpanded.mediaType,
 | |
|       media,
 | |
|       libraryFiles: libraryItemExpanded.libraryFiles
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   static async fullCreateFromOld(oldLibraryItem) {
 | |
|     const newLibraryItem = await this.create(this.getFromOld(oldLibraryItem))
 | |
| 
 | |
|     if (oldLibraryItem.mediaType === 'book') {
 | |
|       const bookObj = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
 | |
|       bookObj.libraryItemId = newLibraryItem.id
 | |
|       const newBook = await this.sequelize.models.book.create(bookObj)
 | |
| 
 | |
|       const oldBookAuthors = oldLibraryItem.media.metadata.authors || []
 | |
|       const oldBookSeriesAll = oldLibraryItem.media.metadata.series || []
 | |
| 
 | |
|       for (const oldBookAuthor of oldBookAuthors) {
 | |
|         await this.sequelize.models.bookAuthor.create({ authorId: oldBookAuthor.id, bookId: newBook.id })
 | |
|       }
 | |
|       for (const oldSeries of oldBookSeriesAll) {
 | |
|         await this.sequelize.models.bookSeries.create({ seriesId: oldSeries.id, bookId: newBook.id, sequence: oldSeries.sequence })
 | |
|       }
 | |
|     } else if (oldLibraryItem.mediaType === 'podcast') {
 | |
|       const podcastObj = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
 | |
|       podcastObj.libraryItemId = newLibraryItem.id
 | |
|       const newPodcast = await this.sequelize.models.podcast.create(podcastObj)
 | |
| 
 | |
|       const oldEpisodes = oldLibraryItem.media.episodes || []
 | |
|       for (const oldEpisode of oldEpisodes) {
 | |
|         const episodeObj = this.sequelize.models.podcastEpisode.getFromOld(oldEpisode)
 | |
|         episodeObj.libraryItemId = newLibraryItem.id
 | |
|         episodeObj.podcastId = newPodcast.id
 | |
|         await this.sequelize.models.podcastEpisode.create(episodeObj)
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return newLibraryItem
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Updates libraryItem, book, authors and series from old library item
 | |
|    *
 | |
|    * @param {oldLibraryItem} oldLibraryItem
 | |
|    * @returns {Promise<boolean>} true if updates were made
 | |
|    */
 | |
|   static async fullUpdateFromOld(oldLibraryItem) {
 | |
|     const libraryItemExpanded = await this.findByPk(oldLibraryItem.id, {
 | |
|       include: [
 | |
|         {
 | |
|           model: this.sequelize.models.book,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.author,
 | |
|               through: {
 | |
|                 attributes: []
 | |
|               }
 | |
|             },
 | |
|             {
 | |
|               model: this.sequelize.models.series,
 | |
|               through: {
 | |
|                 attributes: ['id', 'sequence']
 | |
|               }
 | |
|             }
 | |
|           ]
 | |
|         },
 | |
|         {
 | |
|           model: this.sequelize.models.podcast,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.podcastEpisode
 | |
|             }
 | |
|           ]
 | |
|         }
 | |
|       ]
 | |
|     })
 | |
|     if (!libraryItemExpanded) return false
 | |
| 
 | |
|     let hasUpdates = false
 | |
| 
 | |
|     // Check update Book/Podcast
 | |
|     if (libraryItemExpanded.media) {
 | |
|       let updatedMedia = null
 | |
|       if (libraryItemExpanded.mediaType === 'podcast') {
 | |
|         updatedMedia = this.sequelize.models.podcast.getFromOld(oldLibraryItem.media)
 | |
| 
 | |
|         const existingPodcastEpisodes = libraryItemExpanded.media.podcastEpisodes || []
 | |
|         const updatedPodcastEpisodes = oldLibraryItem.media.episodes || []
 | |
| 
 | |
|         for (const existingPodcastEpisode of existingPodcastEpisodes) {
 | |
|           // Episode was removed
 | |
|           if (!updatedPodcastEpisodes.some((ep) => ep.id === existingPodcastEpisode.id)) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingPodcastEpisode.title}" was removed`)
 | |
|             await existingPodcastEpisode.destroy()
 | |
|             hasUpdates = true
 | |
|           }
 | |
|         }
 | |
|         for (const updatedPodcastEpisode of updatedPodcastEpisodes) {
 | |
|           const existingEpisodeMatch = existingPodcastEpisodes.find((ep) => ep.id === updatedPodcastEpisode.id)
 | |
|           if (!existingEpisodeMatch) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${updatedPodcastEpisode.title}" was added`)
 | |
|             await this.sequelize.models.podcastEpisode.createFromOld(updatedPodcastEpisode)
 | |
|             hasUpdates = true
 | |
|           } else {
 | |
|             const updatedEpisodeCleaned = this.sequelize.models.podcastEpisode.getFromOld(updatedPodcastEpisode)
 | |
|             let episodeHasUpdates = false
 | |
|             for (const key in updatedEpisodeCleaned) {
 | |
|               let existingValue = existingEpisodeMatch[key]
 | |
|               if (existingValue instanceof Date) existingValue = existingValue.valueOf()
 | |
| 
 | |
|               if (!areEquivalent(updatedEpisodeCleaned[key], existingValue, true)) {
 | |
|                 Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" episode "${existingEpisodeMatch.title}" ${key} was updated from %j to %j`, existingValue, updatedEpisodeCleaned[key]))
 | |
|                 episodeHasUpdates = true
 | |
|               }
 | |
|             }
 | |
|             if (episodeHasUpdates) {
 | |
|               await existingEpisodeMatch.update(updatedEpisodeCleaned)
 | |
|               hasUpdates = true
 | |
|             }
 | |
|           }
 | |
|         }
 | |
|       } else if (libraryItemExpanded.mediaType === 'book') {
 | |
|         updatedMedia = this.sequelize.models.book.getFromOld(oldLibraryItem.media)
 | |
| 
 | |
|         const existingAuthors = libraryItemExpanded.media.authors || []
 | |
|         const existingSeriesAll = libraryItemExpanded.media.series || []
 | |
|         const updatedAuthors = oldLibraryItem.media.metadata.authors || []
 | |
|         const uniqueUpdatedAuthors = updatedAuthors.filter((au, idx) => updatedAuthors.findIndex((a) => a.id === au.id) === idx)
 | |
|         const updatedSeriesAll = oldLibraryItem.media.metadata.series || []
 | |
| 
 | |
|         for (const existingAuthor of existingAuthors) {
 | |
|           // Author was removed from Book
 | |
|           if (!uniqueUpdatedAuthors.some((au) => au.id === existingAuthor.id)) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${existingAuthor.name}" was removed`)
 | |
|             await this.sequelize.models.bookAuthor.removeByIds(existingAuthor.id, libraryItemExpanded.media.id)
 | |
|             hasUpdates = true
 | |
|           }
 | |
|         }
 | |
|         for (const updatedAuthor of uniqueUpdatedAuthors) {
 | |
|           // Author was added
 | |
|           if (!existingAuthors.some((au) => au.id === updatedAuthor.id)) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" author "${updatedAuthor.name}" was added`)
 | |
|             await this.sequelize.models.bookAuthor.create({ authorId: updatedAuthor.id, bookId: libraryItemExpanded.media.id })
 | |
|             hasUpdates = true
 | |
|           }
 | |
|         }
 | |
|         for (const existingSeries of existingSeriesAll) {
 | |
|           // Series was removed
 | |
|           if (!updatedSeriesAll.some((se) => se.id === existingSeries.id)) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${existingSeries.name}" was removed`)
 | |
|             await this.sequelize.models.bookSeries.removeByIds(existingSeries.id, libraryItemExpanded.media.id)
 | |
|             hasUpdates = true
 | |
|           }
 | |
|         }
 | |
|         for (const updatedSeries of updatedSeriesAll) {
 | |
|           // Series was added/updated
 | |
|           const existingSeriesMatch = existingSeriesAll.find((se) => se.id === updatedSeries.id)
 | |
|           if (!existingSeriesMatch) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" was added`)
 | |
|             await this.sequelize.models.bookSeries.create({ seriesId: updatedSeries.id, bookId: libraryItemExpanded.media.id, sequence: updatedSeries.sequence })
 | |
|             hasUpdates = true
 | |
|           } else if (existingSeriesMatch.bookSeries.sequence !== updatedSeries.sequence) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" series "${updatedSeries.name}" sequence was updated from "${existingSeriesMatch.bookSeries.sequence}" to "${updatedSeries.sequence}"`)
 | |
|             await existingSeriesMatch.bookSeries.update({ id: updatedSeries.id, sequence: updatedSeries.sequence })
 | |
|             hasUpdates = true
 | |
|           }
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       let hasMediaUpdates = false
 | |
|       for (const key in updatedMedia) {
 | |
|         let existingValue = libraryItemExpanded.media[key]
 | |
|         if (existingValue instanceof Date) existingValue = existingValue.valueOf()
 | |
| 
 | |
|         if (!areEquivalent(updatedMedia[key], existingValue, true)) {
 | |
|           Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" ${libraryItemExpanded.mediaType}.${key} updated from ${existingValue} to ${updatedMedia[key]}`)
 | |
|           hasMediaUpdates = true
 | |
|         }
 | |
|       }
 | |
|       if (hasMediaUpdates && updatedMedia) {
 | |
|         await libraryItemExpanded.media.update(updatedMedia)
 | |
|         hasUpdates = true
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     const updatedLibraryItem = this.getFromOld(oldLibraryItem)
 | |
|     let hasLibraryItemUpdates = false
 | |
|     for (const key in updatedLibraryItem) {
 | |
|       let existingValue = libraryItemExpanded[key]
 | |
|       if (existingValue instanceof Date) existingValue = existingValue.valueOf()
 | |
| 
 | |
|       if (!areEquivalent(updatedLibraryItem[key], existingValue, true)) {
 | |
|         if (key === 'libraryFiles') {
 | |
|           // Handle logging of libraryFiles separately because the object is large (should be addressed when migrating off the old library item model)
 | |
|           const libraryFilesRemoved = libraryItemExpanded.libraryFiles.filter((lf) => !updatedLibraryItem.libraryFiles.some((ulf) => ulf.ino === lf.ino))
 | |
|           if (libraryFilesRemoved.length) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files removed: ${libraryFilesRemoved.map((lf) => lf.metadata.path).join(', ')}`)
 | |
|           }
 | |
|           const libraryFilesAdded = updatedLibraryItem.libraryFiles.filter((ulf) => !libraryItemExpanded.libraryFiles.some((lf) => lf.ino === ulf.ino))
 | |
|           if (libraryFilesAdded.length) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files added: ${libraryFilesAdded.map((lf) => lf.metadata.path).join(', ')}`)
 | |
|           }
 | |
|           if (!libraryFilesRemoved.length && !libraryFilesAdded.length) {
 | |
|             Logger.debug(`[LibraryItem] "${libraryItemExpanded.media.title}" library files updated`)
 | |
|           }
 | |
|         } else {
 | |
|           Logger.debug(util.format(`[LibraryItem] "${libraryItemExpanded.media.title}" ${key} updated from %j to %j`, existingValue, updatedLibraryItem[key]))
 | |
|         }
 | |
| 
 | |
|         hasLibraryItemUpdates = true
 | |
|         if (key === 'updatedAt') {
 | |
|           libraryItemExpanded.changed('updatedAt', true)
 | |
|         }
 | |
|       }
 | |
|     }
 | |
|     if (hasLibraryItemUpdates) {
 | |
|       await libraryItemExpanded.update(updatedLibraryItem)
 | |
|       Logger.info(`[LibraryItem] Library item "${libraryItemExpanded.id}" updated`)
 | |
|       hasUpdates = true
 | |
|     }
 | |
|     return hasUpdates
 | |
|   }
 | |
| 
 | |
|   static getFromOld(oldLibraryItem) {
 | |
|     const extraData = {}
 | |
|     if (oldLibraryItem.oldLibraryItemId) {
 | |
|       extraData.oldLibraryItemId = oldLibraryItem.oldLibraryItemId
 | |
|     }
 | |
|     return {
 | |
|       id: oldLibraryItem.id,
 | |
|       ino: oldLibraryItem.ino,
 | |
|       path: oldLibraryItem.path,
 | |
|       relPath: oldLibraryItem.relPath,
 | |
|       mediaId: oldLibraryItem.media.id,
 | |
|       mediaType: oldLibraryItem.mediaType,
 | |
|       isFile: !!oldLibraryItem.isFile,
 | |
|       isMissing: !!oldLibraryItem.isMissing,
 | |
|       isInvalid: !!oldLibraryItem.isInvalid,
 | |
|       mtime: oldLibraryItem.mtimeMs,
 | |
|       ctime: oldLibraryItem.ctimeMs,
 | |
|       updatedAt: oldLibraryItem.updatedAt,
 | |
|       birthtime: oldLibraryItem.birthtimeMs,
 | |
|       size: oldLibraryItem.size,
 | |
|       lastScan: oldLibraryItem.lastScan,
 | |
|       lastScanVersion: oldLibraryItem.scanVersion,
 | |
|       libraryId: oldLibraryItem.libraryId,
 | |
|       libraryFolderId: oldLibraryItem.folderId,
 | |
|       libraryFiles: oldLibraryItem.libraryFiles?.map((lf) => lf.toJSON()) || [],
 | |
|       extraData
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Remove library item by id
 | |
|    *
 | |
|    * @param {string} libraryItemId
 | |
|    * @returns {Promise<number>} The number of destroyed rows
 | |
|    */
 | |
|   static removeById(libraryItemId) {
 | |
|     return this.destroy({
 | |
|       where: {
 | |
|         id: libraryItemId
 | |
|       },
 | |
|       individualHooks: true
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {string} libraryItemId
 | |
|    * @returns {Promise<LibraryItemExpanded>}
 | |
|    */
 | |
|   static async getExpandedById(libraryItemId) {
 | |
|     if (!libraryItemId) return null
 | |
| 
 | |
|     const libraryItem = await this.findByPk(libraryItemId)
 | |
|     if (!libraryItem) {
 | |
|       Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
 | |
|       return null
 | |
|     }
 | |
| 
 | |
|     if (libraryItem.mediaType === 'podcast') {
 | |
|       libraryItem.media = await libraryItem.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.podcastEpisode
 | |
|           }
 | |
|         ]
 | |
|       })
 | |
|     } else {
 | |
|       libraryItem.media = await libraryItem.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.author,
 | |
|             through: {
 | |
|               attributes: []
 | |
|             }
 | |
|           },
 | |
|           {
 | |
|             model: this.sequelize.models.series,
 | |
|             through: {
 | |
|               attributes: ['sequence']
 | |
|             }
 | |
|           }
 | |
|         ],
 | |
|         order: [
 | |
|           [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
 | |
|           [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
 | |
|         ]
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     if (!libraryItem.media) return null
 | |
|     return libraryItem
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get old library item by id
 | |
|    * @param {string} libraryItemId
 | |
|    * @returns {oldLibraryItem}
 | |
|    */
 | |
|   static async getOldById(libraryItemId) {
 | |
|     if (!libraryItemId) return null
 | |
| 
 | |
|     const libraryItem = await this.findByPk(libraryItemId)
 | |
|     if (!libraryItem) {
 | |
|       Logger.error(`[LibraryItem] Library item not found with id "${libraryItemId}"`)
 | |
|       return null
 | |
|     }
 | |
| 
 | |
|     if (libraryItem.mediaType === 'podcast') {
 | |
|       libraryItem.media = await libraryItem.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.podcastEpisode
 | |
|           }
 | |
|         ]
 | |
|       })
 | |
|     } else {
 | |
|       libraryItem.media = await libraryItem.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.author,
 | |
|             through: {
 | |
|               attributes: []
 | |
|             }
 | |
|           },
 | |
|           {
 | |
|             model: this.sequelize.models.series,
 | |
|             through: {
 | |
|               attributes: ['sequence']
 | |
|             }
 | |
|           }
 | |
|         ],
 | |
|         order: [
 | |
|           [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
 | |
|           [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
 | |
|         ]
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     if (!libraryItem.media) return null
 | |
|     return this.getOldLibraryItem(libraryItem)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get library items using filter and sort
 | |
|    * @param {import('./Library')} library
 | |
|    * @param {import('./User')} user
 | |
|    * @param {object} options
 | |
|    * @returns {{ libraryItems:oldLibraryItem[], count:number }}
 | |
|    */
 | |
|   static async getByFilterAndSort(library, user, options) {
 | |
|     let start = Date.now()
 | |
|     const { libraryItems, count } = await libraryFilters.getFilteredLibraryItems(library.id, user, options)
 | |
|     Logger.debug(`Loaded ${libraryItems.length} of ${count} items for libary page in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|     return {
 | |
|       libraryItems: libraryItems.map((li) => {
 | |
|         const oldLibraryItem = this.getOldLibraryItem(li).toJSONMinified()
 | |
|         if (li.collapsedSeries) {
 | |
|           oldLibraryItem.collapsedSeries = li.collapsedSeries
 | |
|         }
 | |
|         if (li.series) {
 | |
|           oldLibraryItem.media.metadata.series = li.series
 | |
|         }
 | |
|         if (li.rssFeed) {
 | |
|           oldLibraryItem.rssFeed = this.sequelize.models.feed.getOldFeed(li.rssFeed).toJSONMinified()
 | |
|         }
 | |
|         if (li.media.numEpisodes) {
 | |
|           oldLibraryItem.media.numEpisodes = li.media.numEpisodes
 | |
|         }
 | |
|         if (li.size && !oldLibraryItem.media.size) {
 | |
|           oldLibraryItem.media.size = li.size
 | |
|         }
 | |
|         if (li.numEpisodesIncomplete) {
 | |
|           oldLibraryItem.numEpisodesIncomplete = li.numEpisodesIncomplete
 | |
|         }
 | |
|         if (li.mediaItemShare) {
 | |
|           oldLibraryItem.mediaItemShare = li.mediaItemShare
 | |
|         }
 | |
| 
 | |
|         return oldLibraryItem
 | |
|       }),
 | |
|       count
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get home page data personalized shelves
 | |
|    * @param {import('./Library')} library
 | |
|    * @param {import('./User')} user
 | |
|    * @param {string[]} include
 | |
|    * @param {number} limit
 | |
|    * @returns {object[]} array of shelf objects
 | |
|    */
 | |
|   static async getPersonalizedShelves(library, user, include, limit) {
 | |
|     const fullStart = Date.now() // Used for testing load times
 | |
| 
 | |
|     const shelves = []
 | |
| 
 | |
|     // "Continue Listening" shelf
 | |
|     const itemsInProgressPayload = await libraryFilters.getMediaItemsInProgress(library, user, include, limit, false)
 | |
|     if (itemsInProgressPayload.items.length) {
 | |
|       const ebookOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => li.media.isEBookOnly)
 | |
|       const audioOnlyItemsInProgress = itemsInProgressPayload.items.filter((li) => !li.media.isEBookOnly)
 | |
| 
 | |
|       shelves.push({
 | |
|         id: 'continue-listening',
 | |
|         label: 'Continue Listening',
 | |
|         labelStringKey: 'LabelContinueListening',
 | |
|         type: library.isPodcast ? 'episode' : 'book',
 | |
|         entities: audioOnlyItemsInProgress,
 | |
|         total: itemsInProgressPayload.count
 | |
|       })
 | |
| 
 | |
|       if (ebookOnlyItemsInProgress.length) {
 | |
|         // "Continue Reading" shelf
 | |
|         shelves.push({
 | |
|           id: 'continue-reading',
 | |
|           label: 'Continue Reading',
 | |
|           labelStringKey: 'LabelContinueReading',
 | |
|           type: 'book',
 | |
|           entities: ebookOnlyItemsInProgress,
 | |
|           total: itemsInProgressPayload.count
 | |
|         })
 | |
|       }
 | |
|     }
 | |
|     Logger.debug(`Loaded ${itemsInProgressPayload.items.length} of ${itemsInProgressPayload.count} items for "Continue Listening/Reading" in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|     let start = Date.now()
 | |
|     if (library.isBook) {
 | |
|       start = Date.now()
 | |
|       // "Continue Series" shelf
 | |
|       const continueSeriesPayload = await libraryFilters.getLibraryItemsContinueSeries(library, user, include, limit)
 | |
|       if (continueSeriesPayload.libraryItems.length) {
 | |
|         shelves.push({
 | |
|           id: 'continue-series',
 | |
|           label: 'Continue Series',
 | |
|           labelStringKey: 'LabelContinueSeries',
 | |
|           type: 'book',
 | |
|           entities: continueSeriesPayload.libraryItems,
 | |
|           total: continueSeriesPayload.count
 | |
|         })
 | |
|       }
 | |
|       Logger.debug(`Loaded ${continueSeriesPayload.libraryItems.length} of ${continueSeriesPayload.count} items for "Continue Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
|     } else if (library.isPodcast) {
 | |
|       // "Newest Episodes" shelf
 | |
|       const newestEpisodesPayload = await libraryFilters.getNewestPodcastEpisodes(library, user, limit)
 | |
|       if (newestEpisodesPayload.libraryItems.length) {
 | |
|         shelves.push({
 | |
|           id: 'newest-episodes',
 | |
|           label: 'Newest Episodes',
 | |
|           labelStringKey: 'LabelNewestEpisodes',
 | |
|           type: 'episode',
 | |
|           entities: newestEpisodesPayload.libraryItems,
 | |
|           total: newestEpisodesPayload.count
 | |
|         })
 | |
|       }
 | |
|       Logger.debug(`Loaded ${newestEpisodesPayload.libraryItems.length} of ${newestEpisodesPayload.count} episodes for "Newest Episodes" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
|     }
 | |
| 
 | |
|     start = Date.now()
 | |
|     // "Recently Added" shelf
 | |
|     const mostRecentPayload = await libraryFilters.getLibraryItemsMostRecentlyAdded(library, user, include, limit)
 | |
|     if (mostRecentPayload.libraryItems.length) {
 | |
|       shelves.push({
 | |
|         id: 'recently-added',
 | |
|         label: 'Recently Added',
 | |
|         labelStringKey: 'LabelRecentlyAdded',
 | |
|         type: library.mediaType,
 | |
|         entities: mostRecentPayload.libraryItems,
 | |
|         total: mostRecentPayload.count
 | |
|       })
 | |
|     }
 | |
|     Logger.debug(`Loaded ${mostRecentPayload.libraryItems.length} of ${mostRecentPayload.count} items for "Recently Added" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|     if (library.isBook) {
 | |
|       start = Date.now()
 | |
|       // "Recent Series" shelf
 | |
|       const seriesMostRecentPayload = await libraryFilters.getSeriesMostRecentlyAdded(library, user, include, 5)
 | |
|       if (seriesMostRecentPayload.series.length) {
 | |
|         shelves.push({
 | |
|           id: 'recent-series',
 | |
|           label: 'Recent Series',
 | |
|           labelStringKey: 'LabelRecentSeries',
 | |
|           type: 'series',
 | |
|           entities: seriesMostRecentPayload.series,
 | |
|           total: seriesMostRecentPayload.count
 | |
|         })
 | |
|       }
 | |
|       Logger.debug(`Loaded ${seriesMostRecentPayload.series.length} of ${seriesMostRecentPayload.count} series for "Recent Series" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|       start = Date.now()
 | |
|       // "Discover" shelf
 | |
|       const discoverLibraryItemsPayload = await libraryFilters.getLibraryItemsToDiscover(library, user, include, limit)
 | |
|       if (discoverLibraryItemsPayload.libraryItems.length) {
 | |
|         shelves.push({
 | |
|           id: 'discover',
 | |
|           label: 'Discover',
 | |
|           labelStringKey: 'LabelDiscover',
 | |
|           type: library.mediaType,
 | |
|           entities: discoverLibraryItemsPayload.libraryItems,
 | |
|           total: discoverLibraryItemsPayload.count
 | |
|         })
 | |
|       }
 | |
|       Logger.debug(`Loaded ${discoverLibraryItemsPayload.libraryItems.length} of ${discoverLibraryItemsPayload.count} items for "Discover" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
|     }
 | |
| 
 | |
|     start = Date.now()
 | |
|     // "Listen Again" shelf
 | |
|     const mediaFinishedPayload = await libraryFilters.getMediaFinished(library, user, include, limit)
 | |
|     if (mediaFinishedPayload.items.length) {
 | |
|       const ebookOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => li.media.isEBookOnly)
 | |
|       const audioOnlyItemsInProgress = mediaFinishedPayload.items.filter((li) => !li.media.isEBookOnly)
 | |
| 
 | |
|       shelves.push({
 | |
|         id: 'listen-again',
 | |
|         label: 'Listen Again',
 | |
|         labelStringKey: 'LabelListenAgain',
 | |
|         type: library.isPodcast ? 'episode' : 'book',
 | |
|         entities: audioOnlyItemsInProgress,
 | |
|         total: mediaFinishedPayload.count
 | |
|       })
 | |
| 
 | |
|       // "Read Again" shelf
 | |
|       if (ebookOnlyItemsInProgress.length) {
 | |
|         shelves.push({
 | |
|           id: 'read-again',
 | |
|           label: 'Read Again',
 | |
|           labelStringKey: 'LabelReadAgain',
 | |
|           type: 'book',
 | |
|           entities: ebookOnlyItemsInProgress,
 | |
|           total: mediaFinishedPayload.count
 | |
|         })
 | |
|       }
 | |
|     }
 | |
|     Logger.debug(`Loaded ${mediaFinishedPayload.items.length} of ${mediaFinishedPayload.count} items for "Listen/Read Again" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|     if (library.isBook) {
 | |
|       start = Date.now()
 | |
|       // "Newest Authors" shelf
 | |
|       const newestAuthorsPayload = await libraryFilters.getNewestAuthors(library, user, limit)
 | |
|       if (newestAuthorsPayload.authors.length) {
 | |
|         shelves.push({
 | |
|           id: 'newest-authors',
 | |
|           label: 'Newest Authors',
 | |
|           labelStringKey: 'LabelNewestAuthors',
 | |
|           type: 'authors',
 | |
|           entities: newestAuthorsPayload.authors,
 | |
|           total: newestAuthorsPayload.count
 | |
|         })
 | |
|       }
 | |
|       Logger.debug(`Loaded ${newestAuthorsPayload.authors.length} of ${newestAuthorsPayload.count} authors for "Newest Authors" in ${((Date.now() - start) / 1000).toFixed(2)}s`)
 | |
|     }
 | |
| 
 | |
|     Logger.debug(`Loaded ${shelves.length} personalized shelves in ${((Date.now() - fullStart) / 1000).toFixed(2)}s`)
 | |
| 
 | |
|     return shelves
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get book library items for author, optional use user permissions
 | |
|    * @param {oldAuthor} author
 | |
|    * @param {import('./User')} user
 | |
|    * @returns {Promise<oldLibraryItem[]>}
 | |
|    */
 | |
|   static async getForAuthor(author, user = null) {
 | |
|     const { libraryItems } = await libraryFilters.getLibraryItemsForAuthor(author, user, undefined, undefined)
 | |
|     return libraryItems.map((li) => this.getOldLibraryItem(li))
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get book library items in a collection
 | |
|    * @param {oldCollection} collection
 | |
|    * @returns {Promise<oldLibraryItem[]>}
 | |
|    */
 | |
|   static async getForCollection(collection) {
 | |
|     const libraryItems = await libraryFilters.getLibraryItemsForCollection(collection)
 | |
|     return libraryItems.map((li) => this.getOldLibraryItem(li))
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Check if library item exists
 | |
|    * @param {string} libraryItemId
 | |
|    * @returns {Promise<boolean>}
 | |
|    */
 | |
|   static async checkExistsById(libraryItemId) {
 | |
|     return (await this.count({ where: { id: libraryItemId } })) > 0
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {import('sequelize').WhereOptions} where
 | |
|    * @param {import('sequelize').BindOrReplacements} replacements
 | |
|    * @returns {Object} oldLibraryItem
 | |
|    */
 | |
|   static async findOneOld(where, replacements = {}) {
 | |
|     const libraryItem = await this.findOne({
 | |
|       where,
 | |
|       replacements,
 | |
|       include: [
 | |
|         {
 | |
|           model: this.sequelize.models.book,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.author,
 | |
|               through: {
 | |
|                 attributes: []
 | |
|               }
 | |
|             },
 | |
|             {
 | |
|               model: this.sequelize.models.series,
 | |
|               through: {
 | |
|                 attributes: ['sequence']
 | |
|               }
 | |
|             }
 | |
|           ]
 | |
|         },
 | |
|         {
 | |
|           model: this.sequelize.models.podcast,
 | |
|           include: [
 | |
|             {
 | |
|               model: this.sequelize.models.podcastEpisode
 | |
|             }
 | |
|           ]
 | |
|         }
 | |
|       ],
 | |
|       order: [
 | |
|         [this.sequelize.models.book, this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
 | |
|         [this.sequelize.models.book, this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
 | |
|       ]
 | |
|     })
 | |
|     if (!libraryItem) return null
 | |
|     return this.getOldLibraryItem(libraryItem)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @param {import('sequelize').FindOptions} options
 | |
|    * @returns {Promise<Book|Podcast>}
 | |
|    */
 | |
|   getMedia(options) {
 | |
|     if (!this.mediaType) return Promise.resolve(null)
 | |
|     const mixinMethodName = `get${this.sequelize.uppercaseFirst(this.mediaType)}`
 | |
|     return this[mixinMethodName](options)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @returns {Promise<Book|Podcast>}
 | |
|    */
 | |
|   getMediaExpanded() {
 | |
|     if (this.mediaType === 'podcast') {
 | |
|       return this.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.podcastEpisode
 | |
|           }
 | |
|         ]
 | |
|       })
 | |
|     } else {
 | |
|       return this.getMedia({
 | |
|         include: [
 | |
|           {
 | |
|             model: this.sequelize.models.author,
 | |
|             through: {
 | |
|               attributes: []
 | |
|             }
 | |
|           },
 | |
|           {
 | |
|             model: this.sequelize.models.series,
 | |
|             through: {
 | |
|               attributes: ['sequence']
 | |
|             }
 | |
|           }
 | |
|         ],
 | |
|         order: [
 | |
|           [this.sequelize.models.author, this.sequelize.models.bookAuthor, 'createdAt', 'ASC'],
 | |
|           [this.sequelize.models.series, 'bookSeries', 'createdAt', 'ASC']
 | |
|         ]
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    *
 | |
|    * @returns {Promise}
 | |
|    */
 | |
|   async saveMetadataFile() {
 | |
|     let metadataPath = Path.join(global.MetadataPath, 'items', this.id)
 | |
|     let storeMetadataWithItem = global.ServerSettings.storeMetadataWithItem
 | |
|     if (storeMetadataWithItem && !this.isFile) {
 | |
|       metadataPath = this.path
 | |
|     } else {
 | |
|       // Make sure metadata book dir exists
 | |
|       storeMetadataWithItem = false
 | |
|       await fsExtra.ensureDir(metadataPath)
 | |
|     }
 | |
| 
 | |
|     const metadataFilePath = Path.join(metadataPath, `metadata.${global.ServerSettings.metadataFileFormat}`)
 | |
| 
 | |
|     // Expanded with series, authors, podcastEpisodes
 | |
|     const mediaExpanded = this.media || (await this.getMediaExpanded())
 | |
| 
 | |
|     let jsonObject = {}
 | |
|     if (this.mediaType === 'book') {
 | |
|       jsonObject = {
 | |
|         tags: mediaExpanded.tags || [],
 | |
|         chapters: mediaExpanded.chapters?.map((c) => ({ ...c })) || [],
 | |
|         title: mediaExpanded.title,
 | |
|         subtitle: mediaExpanded.subtitle,
 | |
|         authors: mediaExpanded.authors.map((a) => a.name),
 | |
|         narrators: mediaExpanded.narrators,
 | |
|         series: mediaExpanded.series.map((se) => {
 | |
|           const sequence = se.bookSeries?.sequence || ''
 | |
|           if (!sequence) return se.name
 | |
|           return `${se.name} #${sequence}`
 | |
|         }),
 | |
|         genres: mediaExpanded.genres || [],
 | |
|         publishedYear: mediaExpanded.publishedYear,
 | |
|         publishedDate: mediaExpanded.publishedDate,
 | |
|         publisher: mediaExpanded.publisher,
 | |
|         description: mediaExpanded.description,
 | |
|         isbn: mediaExpanded.isbn,
 | |
|         asin: mediaExpanded.asin,
 | |
|         language: mediaExpanded.language,
 | |
|         explicit: !!mediaExpanded.explicit,
 | |
|         abridged: !!mediaExpanded.abridged
 | |
|       }
 | |
|     } else {
 | |
|       jsonObject = {
 | |
|         tags: mediaExpanded.tags || [],
 | |
|         title: mediaExpanded.title,
 | |
|         author: mediaExpanded.author,
 | |
|         description: mediaExpanded.description,
 | |
|         releaseDate: mediaExpanded.releaseDate,
 | |
|         genres: mediaExpanded.genres || [],
 | |
|         feedURL: mediaExpanded.feedURL,
 | |
|         imageURL: mediaExpanded.imageURL,
 | |
|         itunesPageURL: mediaExpanded.itunesPageURL,
 | |
|         itunesId: mediaExpanded.itunesId,
 | |
|         itunesArtistId: mediaExpanded.itunesArtistId,
 | |
|         asin: mediaExpanded.asin,
 | |
|         language: mediaExpanded.language,
 | |
|         explicit: !!mediaExpanded.explicit,
 | |
|         podcastType: mediaExpanded.podcastType
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     return fsExtra
 | |
|       .writeFile(metadataFilePath, JSON.stringify(jsonObject, null, 2))
 | |
|       .then(async () => {
 | |
|         // Add metadata.json to libraryFiles array if it is new
 | |
|         let metadataLibraryFile = this.libraryFiles.find((lf) => lf.metadata.path === filePathToPOSIX(metadataFilePath))
 | |
|         if (storeMetadataWithItem) {
 | |
|           if (!metadataLibraryFile) {
 | |
|             const newLibraryFile = new LibraryFile()
 | |
|             await newLibraryFile.setDataFromPath(metadataFilePath, `metadata.json`)
 | |
|             metadataLibraryFile = newLibraryFile.toJSON()
 | |
|             this.libraryFiles.push(metadataLibraryFile)
 | |
|           } else {
 | |
|             const fileTimestamps = await getFileTimestampsWithIno(metadataFilePath)
 | |
|             if (fileTimestamps) {
 | |
|               metadataLibraryFile.metadata.mtimeMs = fileTimestamps.mtimeMs
 | |
|               metadataLibraryFile.metadata.ctimeMs = fileTimestamps.ctimeMs
 | |
|               metadataLibraryFile.metadata.size = fileTimestamps.size
 | |
|               metadataLibraryFile.ino = fileTimestamps.ino
 | |
|             }
 | |
|           }
 | |
|           const libraryItemDirTimestamps = await getFileTimestampsWithIno(this.path)
 | |
|           if (libraryItemDirTimestamps) {
 | |
|             this.mtime = libraryItemDirTimestamps.mtimeMs
 | |
|             this.ctime = libraryItemDirTimestamps.ctimeMs
 | |
|             let size = 0
 | |
|             this.libraryFiles.forEach((lf) => (size += !isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
 | |
|             this.size = size
 | |
|             await this.save()
 | |
|           }
 | |
|         }
 | |
| 
 | |
|         Logger.debug(`Success saving abmetadata to "${metadataFilePath}"`)
 | |
| 
 | |
|         return metadataLibraryFile
 | |
|       })
 | |
|       .catch((error) => {
 | |
|         Logger.error(`Failed to save json file at "${metadataFilePath}"`, error)
 | |
|         return null
 | |
|       })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Initialize model
 | |
|    * @param {import('../Database').sequelize} sequelize
 | |
|    */
 | |
|   static init(sequelize) {
 | |
|     super.init(
 | |
|       {
 | |
|         id: {
 | |
|           type: DataTypes.UUID,
 | |
|           defaultValue: DataTypes.UUIDV4,
 | |
|           primaryKey: true
 | |
|         },
 | |
|         ino: DataTypes.STRING,
 | |
|         path: DataTypes.STRING,
 | |
|         relPath: DataTypes.STRING,
 | |
|         mediaId: DataTypes.UUIDV4,
 | |
|         mediaType: DataTypes.STRING,
 | |
|         isFile: DataTypes.BOOLEAN,
 | |
|         isMissing: DataTypes.BOOLEAN,
 | |
|         isInvalid: DataTypes.BOOLEAN,
 | |
|         mtime: DataTypes.DATE(6),
 | |
|         ctime: DataTypes.DATE(6),
 | |
|         birthtime: DataTypes.DATE(6),
 | |
|         size: DataTypes.BIGINT,
 | |
|         lastScan: DataTypes.DATE,
 | |
|         lastScanVersion: DataTypes.STRING,
 | |
|         libraryFiles: DataTypes.JSON,
 | |
|         extraData: DataTypes.JSON
 | |
|       },
 | |
|       {
 | |
|         sequelize,
 | |
|         modelName: 'libraryItem',
 | |
|         indexes: [
 | |
|           {
 | |
|             fields: ['createdAt']
 | |
|           },
 | |
|           {
 | |
|             fields: ['mediaId']
 | |
|           },
 | |
|           {
 | |
|             fields: ['libraryId', 'mediaType']
 | |
|           },
 | |
|           {
 | |
|             fields: ['libraryId', 'mediaId', 'mediaType']
 | |
|           },
 | |
|           {
 | |
|             fields: ['birthtime']
 | |
|           },
 | |
|           {
 | |
|             fields: ['mtime']
 | |
|           }
 | |
|         ]
 | |
|       }
 | |
|     )
 | |
| 
 | |
|     const { library, libraryFolder, book, podcast } = sequelize.models
 | |
|     library.hasMany(LibraryItem)
 | |
|     LibraryItem.belongsTo(library)
 | |
| 
 | |
|     libraryFolder.hasMany(LibraryItem)
 | |
|     LibraryItem.belongsTo(libraryFolder)
 | |
| 
 | |
|     book.hasOne(LibraryItem, {
 | |
|       foreignKey: 'mediaId',
 | |
|       constraints: false,
 | |
|       scope: {
 | |
|         mediaType: 'book'
 | |
|       }
 | |
|     })
 | |
|     LibraryItem.belongsTo(book, { foreignKey: 'mediaId', constraints: false })
 | |
| 
 | |
|     podcast.hasOne(LibraryItem, {
 | |
|       foreignKey: 'mediaId',
 | |
|       constraints: false,
 | |
|       scope: {
 | |
|         mediaType: 'podcast'
 | |
|       }
 | |
|     })
 | |
|     LibraryItem.belongsTo(podcast, { foreignKey: 'mediaId', constraints: false })
 | |
| 
 | |
|     LibraryItem.addHook('afterFind', (findResult) => {
 | |
|       if (!findResult) return
 | |
| 
 | |
|       if (!Array.isArray(findResult)) findResult = [findResult]
 | |
|       for (const instance of findResult) {
 | |
|         if (instance.mediaType === 'book' && instance.book !== undefined) {
 | |
|           instance.media = instance.book
 | |
|           instance.dataValues.media = instance.dataValues.book
 | |
|         } else if (instance.mediaType === 'podcast' && instance.podcast !== undefined) {
 | |
|           instance.media = instance.podcast
 | |
|           instance.dataValues.media = instance.dataValues.podcast
 | |
|         }
 | |
|         // To prevent mistakes:
 | |
|         delete instance.book
 | |
|         delete instance.dataValues.book
 | |
|         delete instance.podcast
 | |
|         delete instance.dataValues.podcast
 | |
|       }
 | |
|     })
 | |
| 
 | |
|     LibraryItem.addHook('afterDestroy', async (instance) => {
 | |
|       if (!instance) return
 | |
|       const media = await instance.getMedia()
 | |
|       if (media) {
 | |
|         media.destroy()
 | |
|       }
 | |
|     })
 | |
|   }
 | |
| }
 | |
| 
 | |
| module.exports = LibraryItem
 |