mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-26 08:12:25 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			357 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			357 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| const Path = require('path')
 | |
| const EventEmitter = require('events')
 | |
| const Watcher = require('./libs/watcher/watcher')
 | |
| const Logger = require('./Logger')
 | |
| const LibraryScanner = require('./scanner/LibraryScanner')
 | |
| const Task = require('./objects/Task')
 | |
| const TaskManager = require('./managers/TaskManager')
 | |
| 
 | |
| const { filePathToPOSIX, isSameOrSubPath, getFileMTimeMs } = require('./utils/fileUtils')
 | |
| 
 | |
| /**
 | |
|  * @typedef PendingFileUpdate
 | |
|  * @property {string} path
 | |
|  * @property {string} relPath
 | |
|  * @property {string} folderId
 | |
|  * @property {string} type
 | |
|  */
 | |
| class FolderWatcher extends EventEmitter {
 | |
|   constructor() {
 | |
|     super()
 | |
| 
 | |
|     /** @type {{id:string, name:string, folders:import('./objects/Folder')[], paths:string[], watcher:Watcher[]}[]} */
 | |
|     this.libraryWatchers = []
 | |
|     /** @type {PendingFileUpdate[]} */
 | |
|     this.pendingFileUpdates = []
 | |
|     this.pendingDelay = 4000
 | |
|     /** @type {NodeJS.Timeout} */
 | |
|     this.pendingTimeout = null
 | |
|     /** @type {Task} */
 | |
|     this.pendingTask = null
 | |
| 
 | |
|     this.filesBeingAdded = new Set()
 | |
| 
 | |
|     /** @type {string[]} */
 | |
|     this.ignoreDirs = []
 | |
|     /** @type {string[]} */
 | |
|     this.pendingDirsToRemoveFromIgnore = []
 | |
|     /** @type {NodeJS.Timeout} */
 | |
|     this.removeFromIgnoreTimer = null
 | |
| 
 | |
|     this.disabled = false
 | |
|   }
 | |
| 
 | |
|   get pendingFilePaths() {
 | |
|     return this.pendingFileUpdates.map(f => f.path)
 | |
|   }
 | |
| 
 | |
|   buildLibraryWatcher(library) {
 | |
|     if (this.libraryWatchers.find(w => w.id === library.id)) {
 | |
|       Logger.warn('[Watcher] Already watching library', library.name)
 | |
|       return
 | |
|     }
 | |
|     Logger.info(`[Watcher] Initializing watcher for "${library.name}".`)
 | |
| 
 | |
|     const folderPaths = library.folderPaths
 | |
|     folderPaths.forEach((fp) => {
 | |
|       Logger.debug(`[Watcher] Init watcher for library folder path "${fp}"`)
 | |
|     })
 | |
|     const watcher = new Watcher(folderPaths, {
 | |
|       ignored: /(^|[\/\\])\../, // ignore dotfiles
 | |
|       renameDetection: true,
 | |
|       renameTimeout: 2000,
 | |
|       recursive: true,
 | |
|       ignoreInitial: true,
 | |
|       persistent: true
 | |
|     })
 | |
|     watcher
 | |
|       .on('add', (path) => {
 | |
|         this.onFileAdded(library.id, filePathToPOSIX(path))
 | |
|       }).on('change', (path) => {
 | |
|         // This is triggered from metadata changes, not what we want
 | |
|       }).on('unlink', path => {
 | |
|         this.onFileRemoved(library.id, filePathToPOSIX(path))
 | |
|       }).on('rename', (path, pathNext) => {
 | |
|         this.onFileRename(library.id, filePathToPOSIX(path), filePathToPOSIX(pathNext))
 | |
|       }).on('error', (error) => {
 | |
|         Logger.error(`[Watcher] ${error}`)
 | |
|       }).on('ready', () => {
 | |
|         Logger.info(`[Watcher] "${library.name}" Ready`)
 | |
|       }).on('close', () => {
 | |
|         Logger.debug(`[Watcher] "${library.name}" Closed`)
 | |
|       })
 | |
| 
 | |
|     this.libraryWatchers.push({
 | |
|       id: library.id,
 | |
|       name: library.name,
 | |
|       folders: library.folders,
 | |
|       paths: library.folderPaths,
 | |
|       watcher
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   initWatcher(libraries) {
 | |
|     libraries.forEach((lib) => {
 | |
|       if (!lib.settings.disableWatcher) {
 | |
|         this.buildLibraryWatcher(lib)
 | |
|       }
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   addLibrary(library) {
 | |
|     if (this.disabled || library.settings.disableWatcher) return
 | |
|     this.buildLibraryWatcher(library)
 | |
|   }
 | |
| 
 | |
|   updateLibrary(library) {
 | |
|     if (this.disabled || library.settings.disableWatcher) return
 | |
|     var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
 | |
|     if (libwatcher) {
 | |
|       libwatcher.name = library.name
 | |
| 
 | |
|       // If any folder paths were added or removed then re-init watcher
 | |
|       var pathsToAdd = library.folderPaths.filter(path => !libwatcher.paths.includes(path))
 | |
|       var pathsRemoved = libwatcher.paths.filter(path => !library.folderPaths.includes(path))
 | |
|       if (pathsToAdd.length || pathsRemoved.length) {
 | |
|         Logger.info(`[Watcher] Re-Initializing watcher for "${library.name}".`)
 | |
| 
 | |
|         libwatcher.watcher.close()
 | |
|         this.libraryWatchers = this.libraryWatchers.filter(lw => lw.id !== libwatcher.id)
 | |
|         this.buildLibraryWatcher(library)
 | |
|       }
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   removeLibrary(library) {
 | |
|     if (this.disabled) return
 | |
|     var libwatcher = this.libraryWatchers.find(lib => lib.id === library.id)
 | |
|     if (libwatcher) {
 | |
|       Logger.info(`[Watcher] Removed watcher for "${library.name}"`)
 | |
|       libwatcher.watcher.close()
 | |
|       this.libraryWatchers = this.libraryWatchers.filter(lib => lib.id !== library.id)
 | |
|     } else {
 | |
|       Logger.error(`[Watcher] Library watcher not found for "${library.name}"`)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   close() {
 | |
|     return this.libraryWatchers.map(lib => lib.watcher.close())
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Watcher detected file added
 | |
|    * 
 | |
|    * @param {string} libraryId 
 | |
|    * @param {string} path 
 | |
|    */
 | |
|   onFileAdded(libraryId, path) {
 | |
|     if (this.checkShouldIgnorePath(path)) {
 | |
|       return
 | |
|     }
 | |
|     Logger.debug('[Watcher] File Added', path)
 | |
|     this.addFileUpdate(libraryId, path, 'added')
 | |
| 
 | |
|     if (!this.filesBeingAdded.has(path)) {
 | |
|       this.filesBeingAdded.add(path)
 | |
|       this.waitForFileToAdd(path)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Watcher detected file removed
 | |
|    * 
 | |
|    * @param {string} libraryId 
 | |
|    * @param {string} path 
 | |
|    */
 | |
|   onFileRemoved(libraryId, path) {
 | |
|     if (this.checkShouldIgnorePath(path)) {
 | |
|       return
 | |
|     }
 | |
|     Logger.debug('[Watcher] File Removed', path)
 | |
|     this.addFileUpdate(libraryId, path, 'deleted')
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Watcher detected file renamed
 | |
|    * 
 | |
|    * @param {string} libraryId 
 | |
|    * @param {string} path 
 | |
|    */
 | |
|   onFileRename(libraryId, pathFrom, pathTo) {
 | |
|     if (this.checkShouldIgnorePath(pathTo)) {
 | |
|       return
 | |
|     }
 | |
|     Logger.debug(`[Watcher] Rename ${pathFrom} => ${pathTo}`)
 | |
|     this.addFileUpdate(libraryId, pathTo, 'renamed')
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Get mtimeMs from an added file every second until it is no longer changing
 | |
|    * Times out after 180s
 | |
|    * 
 | |
|    * @param {string} path 
 | |
|    * @param {number} [lastMTimeMs=0] 
 | |
|    * @param {number} [loop=0] 
 | |
|    */
 | |
|   async waitForFileToAdd(path, lastMTimeMs = 0, loop = 0) {
 | |
|     // Safety to catch infinite loop (180s)
 | |
|     if (loop >= 180) {
 | |
|       Logger.warn(`[Watcher] Waiting to add file at "${path}" timeout (loop ${loop}) - proceeding`)
 | |
|       return this.filesBeingAdded.delete(path)
 | |
|     }
 | |
| 
 | |
|     const mtimeMs = await getFileMTimeMs(path)
 | |
|     if (mtimeMs === lastMTimeMs) {
 | |
|       if (lastMTimeMs) Logger.debug(`[Watcher] File finished adding at "${path}"`)
 | |
|       return this.filesBeingAdded.delete(path)
 | |
|     }
 | |
|     if (lastMTimeMs % 5 === 0) {
 | |
|       Logger.debug(`[Watcher] Waiting to add file at "${path}". mtimeMs=${mtimeMs} lastMTimeMs=${lastMTimeMs} (loop ${loop})`)
 | |
|     }
 | |
|     // Wait 1 second
 | |
|     await new Promise((resolve) => setTimeout(resolve, 1000))
 | |
|     this.waitForFileToAdd(path, mtimeMs, ++loop)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Queue file update
 | |
|    * 
 | |
|    * @param {string} libraryId 
 | |
|    * @param {string} path 
 | |
|    * @param {string} type 
 | |
|    */
 | |
|   addFileUpdate(libraryId, path, type) {
 | |
|     if (this.pendingFilePaths.includes(path)) return
 | |
| 
 | |
|     // Get file library
 | |
|     const libwatcher = this.libraryWatchers.find(lw => lw.id === libraryId)
 | |
|     if (!libwatcher) {
 | |
|       Logger.error(`[Watcher] Invalid library id from watcher ${libraryId}`)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     // Get file folder
 | |
|     const folder = libwatcher.folders.find(fold => isSameOrSubPath(fold.fullPath, path))
 | |
|     if (!folder) {
 | |
|       Logger.error(`[Watcher] New file folder not found in library "${libwatcher.name}" with path "${path}"`)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     const folderFullPath = filePathToPOSIX(folder.fullPath)
 | |
| 
 | |
|     const relPath = path.replace(folderFullPath, '')
 | |
| 
 | |
|     if (Path.extname(relPath).toLowerCase() === '.part') {
 | |
|       Logger.debug(`[Watcher] Ignoring .part file "${relPath}"`)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     // Ignore files/folders starting with "."
 | |
|     const hasDotPath = relPath.split('/').find(p => p.startsWith('.'))
 | |
|     if (hasDotPath) {
 | |
|       Logger.debug(`[Watcher] Ignoring dot path "${relPath}" | Piece "${hasDotPath}"`)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     Logger.debug(`[Watcher] Modified file in library "${libwatcher.name}" and folder "${folder.id}" with relPath "${relPath}"`)
 | |
| 
 | |
|     if (!this.pendingTask) {
 | |
|       const taskData = {
 | |
|         libraryId,
 | |
|         libraryName: libwatcher.name
 | |
|       }
 | |
|       this.pendingTask = TaskManager.createAndAddTask('watcher-scan', `Scanning file changes in "${libwatcher.name}"`, null, true, taskData)
 | |
|     }
 | |
|     this.pendingFileUpdates.push({
 | |
|       path,
 | |
|       relPath,
 | |
|       folderId: folder.id,
 | |
|       libraryId,
 | |
|       type
 | |
|     })
 | |
| 
 | |
|     this.handlePendingFileUpdatesTimeout()
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Wait X seconds before notifying scanner that files changed
 | |
|    * reset timer if files are still copying
 | |
|    */
 | |
|   handlePendingFileUpdatesTimeout() {
 | |
|     clearTimeout(this.pendingTimeout)
 | |
|     this.pendingTimeout = setTimeout(() => {
 | |
|       // Check that files are not still being added
 | |
|       if (this.pendingFileUpdates.some(pfu => this.filesBeingAdded.has(pfu.path))) {
 | |
|         Logger.debug(`[Watcher] Still waiting for pending files "${[...this.filesBeingAdded].join(', ')}"`)
 | |
|         return this.handlePendingFileUpdatesTimeout()
 | |
|       }
 | |
| 
 | |
|       LibraryScanner.scanFilesChanged(this.pendingFileUpdates, this.pendingTask)
 | |
|       this.pendingTask = null
 | |
|       this.pendingFileUpdates = []
 | |
|       this.filesBeingAdded.clear()
 | |
|     }, this.pendingDelay)
 | |
|   }
 | |
| 
 | |
|   checkShouldIgnorePath(path) {
 | |
|     return !!this.ignoreDirs.find(dirpath => {
 | |
|       return isSameOrSubPath(dirpath, path)
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Convert to POSIX and remove trailing slash
 | |
|    * @param {string} path 
 | |
|    * @returns {string}
 | |
|    */
 | |
|   cleanDirPath(path) {
 | |
|     path = filePathToPOSIX(path)
 | |
|     if (path.endsWith('/')) path = path.slice(0, -1)
 | |
|     return path
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Ignore this directory if files are picked up by watcher
 | |
|    * @param {string} path 
 | |
|    */
 | |
|   addIgnoreDir(path) {
 | |
|     path = this.cleanDirPath(path)
 | |
|     this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
 | |
|     if (this.ignoreDirs.includes(path)) {
 | |
|       // Already ignoring dir
 | |
|       return
 | |
|     }
 | |
|     Logger.debug(`[Watcher] addIgnoreDir: Ignoring directory "${path}"`)
 | |
|     this.ignoreDirs.push(path)
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * When downloading a podcast episode we dont want the scanner triggering for that podcast
 | |
|    * when the episode finishes the watcher may have a delayed response so a timeout is added
 | |
|    * to prevent the watcher from picking up the episode
 | |
|    * 
 | |
|    * @param {string} path 
 | |
|    */
 | |
|   removeIgnoreDir(path) {
 | |
|     path = this.cleanDirPath(path)
 | |
|     if (!this.ignoreDirs.includes(path)) {
 | |
|       Logger.debug(`[Watcher] removeIgnoreDir: Path is not being ignored "${path}"`)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     // Add a 5 second delay before removing the ignore from this dir
 | |
|     if (!this.pendingDirsToRemoveFromIgnore.includes(path)) {
 | |
|       this.pendingDirsToRemoveFromIgnore.push(path)
 | |
|     }
 | |
| 
 | |
|     clearTimeout(this.removeFromIgnoreTimer)
 | |
|     this.removeFromIgnoreTimer = setTimeout(() => {
 | |
|       if (this.pendingDirsToRemoveFromIgnore.includes(path)) {
 | |
|         this.pendingDirsToRemoveFromIgnore = this.pendingDirsToRemoveFromIgnore.filter(p => p !== path)
 | |
|         Logger.debug(`[Watcher] removeIgnoreDir: No longer ignoring directory "${path}"`)
 | |
|         this.ignoreDirs = this.ignoreDirs.filter(p => p !== path)
 | |
|       }
 | |
|     }, 5000)
 | |
|   }
 | |
| }
 | |
| module.exports = FolderWatcher |