mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-11-03 19:07:00 -05:00 
			
		
		
		
	
		
			
				
	
	
		
			340 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			340 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
const packageJson = require('../../package.json')
 | 
						|
const { LogLevel } = require('../utils/constants')
 | 
						|
const LibraryItem = require('../models/LibraryItem')
 | 
						|
const globals = require('../utils/globals')
 | 
						|
 | 
						|
class LibraryItemScanData {
 | 
						|
  constructor(data) {
 | 
						|
    /** @type {string} */
 | 
						|
    this.libraryFolderId = data.libraryFolderId
 | 
						|
    /** @type {string} */
 | 
						|
    this.libraryId = data.libraryId
 | 
						|
    /** @type {string} */
 | 
						|
    this.mediaType = data.mediaType
 | 
						|
    /** @type {string} */
 | 
						|
    this.ino = data.ino
 | 
						|
    /** @type {number} */
 | 
						|
    this.mtimeMs = data.mtimeMs
 | 
						|
    /** @type {number} */
 | 
						|
    this.ctimeMs = data.ctimeMs
 | 
						|
    /** @type {number} */
 | 
						|
    this.birthtimeMs = data.birthtimeMs
 | 
						|
    /** @type {string} */
 | 
						|
    this.path = data.path
 | 
						|
    /** @type {string} */
 | 
						|
    this.relPath = data.relPath
 | 
						|
    /** @type {boolean} */
 | 
						|
    this.isFile = data.isFile
 | 
						|
    /** @type {import('../utils/scandir').LibraryItemFilenameMetadata} */
 | 
						|
    this.mediaMetadata = data.mediaMetadata
 | 
						|
    /** @type {import('../objects/files/LibraryFile')[]} */
 | 
						|
    this.libraryFiles = data.libraryFiles
 | 
						|
 | 
						|
    // Set after check
 | 
						|
    /** @type {boolean} */
 | 
						|
    this.hasChanges
 | 
						|
    /** @type {boolean} */
 | 
						|
    this.hasPathChange
 | 
						|
    /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
    this.libraryFilesRemoved = []
 | 
						|
    /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
    this.libraryFilesAdded = []
 | 
						|
    /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
    this.libraryFilesModified = []
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Used to create a library item
 | 
						|
   */
 | 
						|
  get libraryItemObject() {
 | 
						|
    let size = 0
 | 
						|
    this.libraryFiles.forEach((lf) => size += (!isNaN(lf.metadata.size) ? Number(lf.metadata.size) : 0))
 | 
						|
    return {
 | 
						|
      ino: this.ino,
 | 
						|
      path: this.path,
 | 
						|
      relPath: this.relPath,
 | 
						|
      mediaType: this.mediaType,
 | 
						|
      isFile: this.isFile,
 | 
						|
      mtime: this.mtimeMs,
 | 
						|
      ctime: this.ctimeMs,
 | 
						|
      birthtime: this.birthtimeMs,
 | 
						|
      lastScan: Date.now(),
 | 
						|
      lastScanVersion: packageJson.version,
 | 
						|
      libraryFiles: this.libraryFiles,
 | 
						|
      libraryId: this.libraryId,
 | 
						|
      libraryFolderId: this.libraryFolderId,
 | 
						|
      size
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {boolean} */
 | 
						|
  get hasLibraryFileChanges() {
 | 
						|
    return this.libraryFilesRemoved.length + this.libraryFilesModified.length + this.libraryFilesAdded.length
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {boolean} */
 | 
						|
  get hasAudioFileChanges() {
 | 
						|
    return (this.audioLibraryFilesRemoved.length + this.audioLibraryFilesAdded.length + this.audioLibraryFilesModified.length) > 0
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
  get audioLibraryFilesModified() {
 | 
						|
    return this.libraryFilesModified.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
  get audioLibraryFilesRemoved() {
 | 
						|
    return this.libraryFilesRemoved.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
  get audioLibraryFilesAdded() {
 | 
						|
    return this.libraryFilesAdded.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
  get audioLibraryFiles() {
 | 
						|
    return this.libraryFiles.filter(lf => globals.SupportedAudioTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject[]} */
 | 
						|
  get imageLibraryFiles() {
 | 
						|
    return this.libraryFiles.filter(lf => globals.SupportedImageTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {import('../objects/files/LibraryFile')[]} */
 | 
						|
  get ebookLibraryFiles() {
 | 
						|
    return this.libraryFiles.filter(lf => globals.SupportedEbookTypes.includes(lf.metadata.ext?.slice(1).toLowerCase() || ''))
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get descTxtLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.filename === 'desc.txt')
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get readerTxtLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.filename === 'reader.txt')
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get metadataAbsLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.abs')
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get metadataJsonLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.filename === 'metadata.json')
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get metadataOpfLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.opf')
 | 
						|
  }
 | 
						|
 | 
						|
  /** @type {LibraryItem.LibraryFileObject} */
 | 
						|
  get metadataNfoLibraryFile() {
 | 
						|
    return this.libraryFiles.find(lf => lf.metadata.ext.toLowerCase() === '.nfo')
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 
 | 
						|
   * @param {LibraryItem} existingLibraryItem 
 | 
						|
   * @param {import('./LibraryScan')} libraryScan
 | 
						|
   * @returns {boolean} true if changes found
 | 
						|
   */
 | 
						|
  async checkLibraryItemData(existingLibraryItem, libraryScan) {
 | 
						|
    const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile']
 | 
						|
    this.hasChanges = false
 | 
						|
    this.hasPathChange = false
 | 
						|
    for (const key of keysToCompare) {
 | 
						|
      if (existingLibraryItem[key] !== this[key]) {
 | 
						|
        libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "${key}" changed from "${existingLibraryItem[key]}" to "${this[key]}"`)
 | 
						|
        existingLibraryItem[key] = this[key]
 | 
						|
        this.hasChanges = true
 | 
						|
 | 
						|
        if (key === 'relPath') {
 | 
						|
          this.hasPathChange = true
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Check mtime, ctime and birthtime
 | 
						|
    if (existingLibraryItem.mtime?.valueOf() !== this.mtimeMs) {
 | 
						|
      libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "mtime" changed from "${existingLibraryItem.mtime?.valueOf()}" to "${this.mtimeMs}"`)
 | 
						|
      existingLibraryItem.mtime = this.mtimeMs
 | 
						|
      this.hasChanges = true
 | 
						|
    }
 | 
						|
    if (existingLibraryItem.birthtime?.valueOf() !== this.birthtimeMs) {
 | 
						|
      libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "birthtime" changed from "${existingLibraryItem.birthtime?.valueOf()}" to "${this.birthtimeMs}"`)
 | 
						|
      existingLibraryItem.birthtime = this.birthtimeMs
 | 
						|
      this.hasChanges = true
 | 
						|
    }
 | 
						|
    if (existingLibraryItem.ctime?.valueOf() !== this.ctimeMs) {
 | 
						|
      libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" key "ctime" changed from "${existingLibraryItem.ctime?.valueOf()}" to "${this.ctimeMs}"`)
 | 
						|
      existingLibraryItem.ctime = this.ctimeMs
 | 
						|
      this.hasChanges = true
 | 
						|
    }
 | 
						|
    if (existingLibraryItem.isMissing) {
 | 
						|
      libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" was missing but now found`)
 | 
						|
      existingLibraryItem.isMissing = false
 | 
						|
      this.hasChanges = true
 | 
						|
    }
 | 
						|
 | 
						|
    this.libraryFilesRemoved = []
 | 
						|
    this.libraryFilesModified = []
 | 
						|
    let libraryFilesAdded = this.libraryFiles.map(lf => lf)
 | 
						|
 | 
						|
    for (const existingLibraryFile of existingLibraryItem.libraryFiles) {
 | 
						|
      // Find matching library file using path first and fallback to using inode value
 | 
						|
      let matchingLibraryFile = this.libraryFiles.find(lf => lf.metadata.path === existingLibraryFile.metadata.path)
 | 
						|
      if (!matchingLibraryFile) {
 | 
						|
        matchingLibraryFile = this.libraryFiles.find(lf => lf.ino === existingLibraryFile.ino)
 | 
						|
        if (matchingLibraryFile) {
 | 
						|
          libraryScan.addLog(LogLevel.INFO, `Library file with path "${existingLibraryFile.metadata.path}" not found, but found file with matching inode value "${existingLibraryFile.ino}" at path "${matchingLibraryFile.metadata.path}"`)
 | 
						|
        }
 | 
						|
      }
 | 
						|
 | 
						|
      if (!matchingLibraryFile) { // Library file removed
 | 
						|
        libraryScan.addLog(LogLevel.INFO, `Library file "${existingLibraryFile.metadata.path}" was removed from library item "${existingLibraryItem.relPath}"`)
 | 
						|
        this.libraryFilesRemoved.push(existingLibraryFile)
 | 
						|
        existingLibraryItem.libraryFiles = existingLibraryItem.libraryFiles.filter(lf => lf !== existingLibraryFile)
 | 
						|
        this.hasChanges = true
 | 
						|
      } else {
 | 
						|
        libraryFilesAdded = libraryFilesAdded.filter(lf => lf !== matchingLibraryFile)
 | 
						|
        if (this.compareUpdateLibraryFile(existingLibraryItem.path, existingLibraryFile, matchingLibraryFile, libraryScan)) {
 | 
						|
          this.libraryFilesModified.push(existingLibraryFile)
 | 
						|
          this.hasChanges = true
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // Log new library files found
 | 
						|
    if (libraryFilesAdded.length) {
 | 
						|
      this.hasChanges = true
 | 
						|
      for (const libraryFile of libraryFilesAdded) {
 | 
						|
        libraryScan.addLog(LogLevel.INFO, `New library file found with path "${libraryFile.metadata.path}" for library item "${existingLibraryItem.relPath}"`)
 | 
						|
        if (libraryFile.isEBookFile) {
 | 
						|
          // Set all new ebook files as supplementary
 | 
						|
          libraryFile.isSupplementary = true
 | 
						|
        }
 | 
						|
        existingLibraryItem.libraryFiles.push(libraryFile.toJSON())
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    this.libraryFilesAdded = libraryFilesAdded
 | 
						|
 | 
						|
    if (this.hasChanges) {
 | 
						|
      existingLibraryItem.size = 0
 | 
						|
      existingLibraryItem.libraryFiles.forEach((lf) => existingLibraryItem.size += lf.metadata.size)
 | 
						|
 | 
						|
      existingLibraryItem.lastScan = Date.now()
 | 
						|
      existingLibraryItem.lastScanVersion = packageJson.version
 | 
						|
 | 
						|
      libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.relPath}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`)
 | 
						|
 | 
						|
      if (this.hasLibraryFileChanges) {
 | 
						|
        existingLibraryItem.changed('libraryFiles', true)
 | 
						|
      }
 | 
						|
      await existingLibraryItem.save()
 | 
						|
      return true
 | 
						|
    }
 | 
						|
 | 
						|
    return false
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Update existing library file with scanned in library file data
 | 
						|
   * @param {string} libraryItemPath
 | 
						|
   * @param {LibraryItem.LibraryFileObject} existingLibraryFile 
 | 
						|
   * @param {import('../objects/files/LibraryFile')} scannedLibraryFile 
 | 
						|
   * @param {import('./LibraryScan')} libraryScan
 | 
						|
   * @returns {boolean} false if no changes
 | 
						|
   */
 | 
						|
  compareUpdateLibraryFile(libraryItemPath, existingLibraryFile, scannedLibraryFile, libraryScan) {
 | 
						|
    let hasChanges = false
 | 
						|
 | 
						|
    if (existingLibraryFile.ino !== scannedLibraryFile.ino) {
 | 
						|
      existingLibraryFile.ino = scannedLibraryFile.ino
 | 
						|
      hasChanges = true
 | 
						|
    }
 | 
						|
 | 
						|
    for (const key in existingLibraryFile.metadata) {
 | 
						|
      if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) {
 | 
						|
        if (key !== 'path' && key !== 'relPath') {
 | 
						|
          libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.relPath}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
 | 
						|
        } else {
 | 
						|
          libraryScan.addLog(LogLevel.DEBUG, `Library file for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`)
 | 
						|
        }
 | 
						|
        existingLibraryFile.metadata[key] = scannedLibraryFile.metadata[key]
 | 
						|
        hasChanges = true
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    if (hasChanges) {
 | 
						|
      existingLibraryFile.updatedAt = Date.now()
 | 
						|
    }
 | 
						|
 | 
						|
    return hasChanges
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Check if existing audio file on Book was removed
 | 
						|
   * @param {import('../models/Book').AudioFileObject} existingAudioFile 
 | 
						|
   * @returns {boolean} true if audio file was removed
 | 
						|
   */
 | 
						|
  checkAudioFileRemoved(existingAudioFile) {
 | 
						|
    if (!this.audioLibraryFilesRemoved.length) return false
 | 
						|
    // First check exact path
 | 
						|
    if (this.audioLibraryFilesRemoved.some(af => af.metadata.path === existingAudioFile.metadata.path)) {
 | 
						|
      return true
 | 
						|
    }
 | 
						|
    // Fallback to check inode value
 | 
						|
    return this.audioLibraryFilesRemoved.some(af => af.ino === existingAudioFile.ino)
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Check if existing ebook file on Book was removed
 | 
						|
   * @param {import('../models/Book').EBookFileObject} ebookFile 
 | 
						|
   * @returns {boolean} true if ebook file was removed
 | 
						|
   */
 | 
						|
  checkEbookFileRemoved(ebookFile) {
 | 
						|
    if (!this.ebookLibraryFiles.length) return true
 | 
						|
 | 
						|
    if (this.ebookLibraryFiles.some(lf => lf.metadata.path === ebookFile.metadata.path)) {
 | 
						|
      return false
 | 
						|
    }
 | 
						|
 | 
						|
    return !this.ebookLibraryFiles.some(lf => lf.ino === ebookFile.ino)
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Set data parsed from filenames
 | 
						|
   * 
 | 
						|
   * @param {Object} bookMetadata 
 | 
						|
   */
 | 
						|
  setBookMetadataFromFilenames(bookMetadata) {
 | 
						|
    const keysToMap = ['title', 'subtitle', 'publishedYear', 'asin']
 | 
						|
    for (const key in this.mediaMetadata) {
 | 
						|
      if (keysToMap.includes(key) && this.mediaMetadata[key]) {
 | 
						|
        bookMetadata[key] = this.mediaMetadata[key]
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    if (this.mediaMetadata.authors?.length) {
 | 
						|
      bookMetadata.authors = this.mediaMetadata.authors
 | 
						|
    }
 | 
						|
    if (this.mediaMetadata.narrators?.length) {
 | 
						|
      bookMetadata.narrators = this.mediaMetadata.narrators
 | 
						|
    }
 | 
						|
    if (this.mediaMetadata.seriesName) {
 | 
						|
      bookMetadata.series = [
 | 
						|
        {
 | 
						|
          name: this.mediaMetadata.seriesName,
 | 
						|
          sequence: this.mediaMetadata.seriesSequence || null
 | 
						|
        }
 | 
						|
      ]
 | 
						|
    }
 | 
						|
  }
 | 
						|
}
 | 
						|
module.exports = LibraryItemScanData |