mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-31 18:37:00 -04:00 
			
		
		
		
	Auto add/update/remove audiobooks, update screenshots
This commit is contained in:
		
							parent
							
								
									2e82370408
								
							
						
					
					
						commit
						07a2a0aefd
					
				| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "1.0.8", |   "version": "1.1.0", | ||||||
|   "description": "Audiobook manager and player", |   "description": "Audiobook manager and player", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 168 KiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.1 MiB | 
										
											Binary file not shown.
										
									
								
							| Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB | 
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf", |   "name": "audiobookshelf", | ||||||
|   "version": "1.0.8", |   "version": "1.1.0", | ||||||
|   "description": "Self-hosted audiobook server for managing and playing audiobooks.", |   "description": "Self-hosted audiobook server for managing and playing audiobooks.", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -28,8 +28,6 @@ will store "With a Subtitle" as the subtitle | |||||||
| 
 | 
 | ||||||
| #### Features coming soon: | #### Features coming soon: | ||||||
| 
 | 
 | ||||||
| * Auto add and update audiobooks (currently you need to press scan) |  | ||||||
| * User permissions & editing users |  | ||||||
| * Support different views to see more details of each audiobook | * Support different views to see more details of each audiobook | ||||||
| * Option to download all files in a zip file | * Option to download all files in a zip file | ||||||
| * iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)) | * iOS App (Android is in beta [here](https://play.google.com/store/apps/details?id=com.audiobookshelf.app)) | ||||||
|  | |||||||
| @ -1,10 +1,13 @@ | |||||||
|  | const fs = require('fs-extra') | ||||||
| const Logger = require('./Logger') | const Logger = require('./Logger') | ||||||
| const BookFinder = require('./BookFinder') | const BookFinder = require('./BookFinder') | ||||||
| const Audiobook = require('./objects/Audiobook') | const Audiobook = require('./objects/Audiobook') | ||||||
| const audioFileScanner = require('./utils/audioFileScanner') | const audioFileScanner = require('./utils/audioFileScanner') | ||||||
| const { getAllAudiobookFiles } = require('./utils/scandir') | const { getAllAudiobookFileData, getAudiobookFileData } = require('./utils/scandir') | ||||||
| const { comparePaths, getIno } = require('./utils/index') | const { comparePaths, getIno } = require('./utils/index') | ||||||
| const { secondsToTimestamp } = require('./utils/fileUtils') | const { secondsToTimestamp } = require('./utils/fileUtils') | ||||||
|  | const { ScanResult } = require('./utils/constants') | ||||||
|  | 
 | ||||||
| 
 | 
 | ||||||
| class Scanner { | class Scanner { | ||||||
|   constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) { |   constructor(AUDIOBOOK_PATH, METADATA_PATH, db, emitter) { | ||||||
| @ -60,6 +63,110 @@ class Scanner { | |||||||
|     return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino) |     return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async scanAudiobookData(audiobookData) { | ||||||
|  |     var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino) | ||||||
|  |     Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`) | ||||||
|  | 
 | ||||||
|  |     if (existingAudiobook) { | ||||||
|  | 
 | ||||||
|  |       // REMOVE: No valid audio files
 | ||||||
|  |       if (!audiobookData.audioFiles.length) { | ||||||
|  |         Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`) | ||||||
|  | 
 | ||||||
|  |         await this.db.removeEntity('audiobook', existingAudiobook.id) | ||||||
|  |         this.emitter('audiobook_removed', existingAudiobook.toJSONMinified()) | ||||||
|  | 
 | ||||||
|  |         return ScanResult.REMOVED | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles) | ||||||
|  | 
 | ||||||
|  |       // Check for audio files that were removed
 | ||||||
|  |       var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino) | ||||||
|  |       var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino)) | ||||||
|  |       if (removedAudioFiles.length) { | ||||||
|  |         Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`) | ||||||
|  |         removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af)) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Check for new audio files and sync existing audio files
 | ||||||
|  |       var newAudioFiles = [] | ||||||
|  |       var hasUpdatedAudioFiles = false | ||||||
|  |       audiobookData.audioFiles.forEach((file) => { | ||||||
|  |         var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino) | ||||||
|  |         if (existingAudioFile) { // Audio file exists, sync paths
 | ||||||
|  |           if (existingAudiobook.syncAudioFile(existingAudioFile, file)) { | ||||||
|  |             hasUpdatedAudioFiles = true | ||||||
|  |           } | ||||||
|  |         } else { | ||||||
|  |           newAudioFiles.push(file) | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |       if (newAudioFiles.length) { | ||||||
|  |         Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`) | ||||||
|  |         // Scan new audio files found - sets tracks
 | ||||||
|  |         await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles) | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  |       // REMOVE: No valid audio tracks
 | ||||||
|  |       if (!existingAudiobook.tracks.length) { | ||||||
|  |         Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`) | ||||||
|  | 
 | ||||||
|  |         await this.db.removeEntity('audiobook', existingAudiobook.id) | ||||||
|  |         this.emitter('audiobook_removed', existingAudiobook.toJSONMinified()) | ||||||
|  |         return ScanResult.REMOVED | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles | ||||||
|  | 
 | ||||||
|  |       if (existingAudiobook.checkUpdateMissingParts()) { | ||||||
|  |         Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`) | ||||||
|  |         hasUpdates = true | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) { | ||||||
|  |         hasUpdates = true | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       // Syncs path and fullPath
 | ||||||
|  |       if (existingAudiobook.syncPaths(audiobookData)) { | ||||||
|  |         hasUpdates = true | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       if (hasUpdates) { | ||||||
|  |         Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`) | ||||||
|  |         existingAudiobook.lastUpdate = Date.now() | ||||||
|  |         await this.db.updateAudiobook(existingAudiobook) | ||||||
|  |         this.emitter('audiobook_updated', existingAudiobook.toJSONMinified()) | ||||||
|  | 
 | ||||||
|  |         return ScanResult.UPDATED | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       return ScanResult.UPTODATE | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // NEW: Check new audiobook
 | ||||||
|  |     if (!audiobookData.audioFiles.length) { | ||||||
|  |       Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path) | ||||||
|  |       return ScanResult.NOTHING | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var audiobook = new Audiobook() | ||||||
|  |     audiobook.setData(audiobookData) | ||||||
|  |     await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles) | ||||||
|  |     if (!audiobook.tracks.length) { | ||||||
|  |       Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title) | ||||||
|  |       return ScanResult.NOTHING | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     audiobook.checkUpdateMissingParts() | ||||||
|  |     Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`) | ||||||
|  |     await this.db.insertAudiobook(audiobook) | ||||||
|  |     this.emitter('audiobook_added', audiobook.toJSONMinified()) | ||||||
|  |     return ScanResult.ADDED | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async scan() { |   async scan() { | ||||||
|     // TEMP - fix relative file paths
 |     // TEMP - fix relative file paths
 | ||||||
|     // TEMP - update ino for each audiobook
 |     // TEMP - update ino for each audiobook
 | ||||||
| @ -80,7 +187,7 @@ class Scanner { | |||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const scanStart = Date.now() |     const scanStart = Date.now() | ||||||
|     var audiobookDataFound = await getAllAudiobookFiles(this.AudiobookPath, this.db.serverSettings) |     var audiobookDataFound = await getAllAudiobookFileData(this.AudiobookPath, this.db.serverSettings) | ||||||
| 
 | 
 | ||||||
|     // Set ino for each ab data as a string
 |     // Set ino for each ab data as a string
 | ||||||
|     audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) |     audiobookDataFound = await this.setAudiobookDataInos(audiobookDataFound) | ||||||
| @ -112,97 +219,14 @@ class Scanner { | |||||||
|       } |       } | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     // Check for new and updated audiobooks
 | ||||||
|     for (let i = 0; i < audiobookDataFound.length; i++) { |     for (let i = 0; i < audiobookDataFound.length; i++) { | ||||||
|       var audiobookData = audiobookDataFound[i] |       var audiobookData = audiobookDataFound[i] | ||||||
|       var existingAudiobook = this.audiobooks.find(a => a.ino === audiobookData.ino) |       var result = await this.scanAudiobookData(audiobookData) | ||||||
|       Logger.debug(`[Scanner] Scanning "${audiobookData.title}" (${audiobookData.ino}) - ${!!existingAudiobook ? 'Exists' : 'New'}`) |       if (result === ScanResult.ADDED) scanResults.added++ | ||||||
|  |       if (result === ScanResult.REMOVED) scanResults.removed++ | ||||||
|  |       if (result === ScanResult.UPDATED) scanResults.updated++ | ||||||
| 
 | 
 | ||||||
|       if (existingAudiobook) { |  | ||||||
|         if (!audiobookData.audioFiles.length) { |  | ||||||
|           Logger.error(`[Scanner] "${existingAudiobook.title}" no valid audio files found - removing audiobook`) |  | ||||||
| 
 |  | ||||||
|           await this.db.removeEntity('audiobook', existingAudiobook.id) |  | ||||||
|           this.emitter('audiobook_removed', existingAudiobook.toJSONMinified()) |  | ||||||
|           scanResults.removed++ |  | ||||||
|         } else { |  | ||||||
|           audiobookData.audioFiles = await this.setAudioFileInos(audiobookData.audioFiles, existingAudiobook.audioFiles) |  | ||||||
|           var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino) |  | ||||||
| 
 |  | ||||||
|           // Check for audio files that were removed
 |  | ||||||
|           var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino)) |  | ||||||
|           if (removedAudioFiles.length) { |  | ||||||
|             Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`) |  | ||||||
|             removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af)) |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           // Check for new audio files and sync existing audio files
 |  | ||||||
|           var newAudioFiles = [] |  | ||||||
|           var hasUpdatedAudioFiles = false |  | ||||||
|           audiobookData.audioFiles.forEach((file) => { |  | ||||||
|             var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino) |  | ||||||
|             if (existingAudioFile) { // Audio file exists, sync paths
 |  | ||||||
|               if (existingAudiobook.syncAudioFile(existingAudioFile, file)) { |  | ||||||
|                 hasUpdatedAudioFiles = true |  | ||||||
|               } |  | ||||||
|             } else { |  | ||||||
|               newAudioFiles.push(file) |  | ||||||
|             } |  | ||||||
|           }) |  | ||||||
|           if (newAudioFiles.length) { |  | ||||||
|             Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`) |  | ||||||
|             // Scan new audio files found
 |  | ||||||
|             await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles) |  | ||||||
|           } |  | ||||||
| 
 |  | ||||||
|           if (!existingAudiobook.tracks.length) { |  | ||||||
|             Logger.error(`[Scanner] "${existingAudiobook.title}" has no valid tracks after update - removing audiobook`) |  | ||||||
| 
 |  | ||||||
|             await this.db.removeEntity('audiobook', existingAudiobook.id) |  | ||||||
|             this.emitter('audiobook_removed', existingAudiobook.toJSONMinified()) |  | ||||||
|           } else { |  | ||||||
|             var hasUpdates = removedAudioFiles.length || newAudioFiles.length || hasUpdatedAudioFiles |  | ||||||
| 
 |  | ||||||
|             if (existingAudiobook.checkUpdateMissingParts()) { |  | ||||||
|               Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`) |  | ||||||
|               hasUpdates = true |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             if (existingAudiobook.syncOtherFiles(audiobookData.otherFiles)) { |  | ||||||
|               hasUpdates = true |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             // Syncs path and fullPath
 |  | ||||||
|             if (existingAudiobook.syncPaths(audiobookData)) { |  | ||||||
|               hasUpdates = true |  | ||||||
|             } |  | ||||||
| 
 |  | ||||||
|             if (hasUpdates) { |  | ||||||
|               Logger.info(`[Scanner] "${existingAudiobook.title}" was updated - saving`) |  | ||||||
|               existingAudiobook.lastUpdate = Date.now() |  | ||||||
|               await this.db.updateAudiobook(existingAudiobook) |  | ||||||
|               this.emitter('audiobook_updated', existingAudiobook.toJSONMinified()) |  | ||||||
|               scanResults.updated++ |  | ||||||
|             } |  | ||||||
|           } |  | ||||||
|         } // end if update existing
 |  | ||||||
|       } else { |  | ||||||
|         if (!audiobookData.audioFiles.length) { |  | ||||||
|           Logger.error('[Scanner] No valid audio tracks for Audiobook', audiobookData.path) |  | ||||||
|         } else { |  | ||||||
|           var audiobook = new Audiobook() |  | ||||||
|           audiobook.setData(audiobookData) |  | ||||||
|           await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles) |  | ||||||
|           if (!audiobook.tracks.length) { |  | ||||||
|             Logger.warn('[Scanner] Invalid audiobook, no valid tracks', audiobook.title) |  | ||||||
|           } else { |  | ||||||
|             audiobook.checkUpdateMissingParts() |  | ||||||
|             Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`) |  | ||||||
|             await this.db.insertAudiobook(audiobook) |  | ||||||
|             this.emitter('audiobook_added', audiobook.toJSONMinified()) |  | ||||||
|             scanResults.added++ |  | ||||||
|           } |  | ||||||
|         } // end if add new
 |  | ||||||
|       } |  | ||||||
|       var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) |       var progress = Math.round(100 * (i + 1) / audiobookDataFound.length) | ||||||
|       this.emitter('scan_progress', { |       this.emitter('scan_progress', { | ||||||
|         scanType: 'files', |         scanType: 'files', | ||||||
| @ -222,6 +246,29 @@ class Scanner { | |||||||
|     return scanResults |     return scanResults | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   async scanAudiobook(audiobookPath) { | ||||||
|  |     var exists = await fs.pathExists(audiobookPath) | ||||||
|  |     if (!exists) { | ||||||
|  |       // Audiobook was deleted, TODO: Should confirm this better
 | ||||||
|  |       var audiobook = this.db.audiobooks.find(ab => ab.fullPath === audiobookPath) | ||||||
|  |       if (audiobook) { | ||||||
|  |         var audiobookJSON = audiobook.toJSONMinified() | ||||||
|  |         await this.db.removeEntity('audiobook', audiobook.id) | ||||||
|  |         this.emitter('audiobook_removed', audiobookJSON) | ||||||
|  |         return ScanResult.REMOVED | ||||||
|  |       } | ||||||
|  |       Logger.warn('Path was deleted but no audiobook found', audiobookPath) | ||||||
|  |       return ScanResult.NOTHING | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var audiobookData = await getAudiobookFileData(this.AudiobookPath, audiobookPath, this.db.serverSettings) | ||||||
|  |     if (!audiobookData) { | ||||||
|  |       return ScanResult.NOTHING | ||||||
|  |     } | ||||||
|  |     audiobookData.ino = await getIno(audiobookData.fullPath) | ||||||
|  |     return this.scanAudiobookData(audiobookData) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   async fetchMetadata(id, trackIndex = 0) { |   async fetchMetadata(id, trackIndex = 0) { | ||||||
|     var audiobook = this.audiobooks.find(a => a.id === id) |     var audiobook = this.audiobooks.find(a => a.id === id) | ||||||
|     if (!audiobook) { |     if (!audiobook) { | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ const StreamManager = require('./StreamManager') | |||||||
| const RssFeeds = require('./RssFeeds') | const RssFeeds = require('./RssFeeds') | ||||||
| const DownloadManager = require('./DownloadManager') | const DownloadManager = require('./DownloadManager') | ||||||
| const Logger = require('./Logger') | const Logger = require('./Logger') | ||||||
|  | const { ScanResult } = require('./utils/constants') | ||||||
| 
 | 
 | ||||||
| class Server { | class Server { | ||||||
|   constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { |   constructor(PORT, CONFIG_PATH, METADATA_PATH, AUDIOBOOK_PATH) { | ||||||
| @ -75,8 +76,21 @@ class Server { | |||||||
|     }) |     }) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   async fileAddedUpdated({ path, fullPath }) { } |   async newFilesAdded({ dir, files }) { | ||||||
|   async fileRemoved({ path, fullPath }) { } |     Logger.info(files.length, 'New Files Added in dir', dir) | ||||||
|  |     var result = await this.scanner.scanAudiobook(dir) | ||||||
|  |     Logger.info('New Files Added result', result) | ||||||
|  |   } | ||||||
|  |   async filesRemoved({ dir, files }) { | ||||||
|  |     Logger.info(files.length, 'Files Removed in dir', dir) | ||||||
|  |     var result = await this.scanner.scanAudiobook(dir) | ||||||
|  |     Logger.info('Files Removed result', result) | ||||||
|  |   } | ||||||
|  |   async filesRenamed({ dir, files }) { | ||||||
|  |     Logger.info(files.length, 'Files Renamed in dir', dir) | ||||||
|  |     var result = await this.scanner.scanAudiobook(dir) | ||||||
|  |     Logger.info('Files Renamed result', result) | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   async scan() { |   async scan() { | ||||||
|     Logger.info('[Server] Starting Scan') |     Logger.info('[Server] Starting Scan') | ||||||
| @ -112,9 +126,9 @@ class Server { | |||||||
|     this.auth.init() |     this.auth.init() | ||||||
| 
 | 
 | ||||||
|     this.watcher.initWatcher() |     this.watcher.initWatcher() | ||||||
|     this.watcher.on('file_added', this.fileAddedUpdated.bind(this)) |     this.watcher.on('new_files', this.newFilesAdded.bind(this)) | ||||||
|     this.watcher.on('file_removed', this.fileRemoved.bind(this)) |     this.watcher.on('removed_files', this.filesRemoved.bind(this)) | ||||||
|     this.watcher.on('file_updated', this.fileAddedUpdated.bind(this)) |     this.watcher.on('renamed_files', this.filesRenamed.bind(this)) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   authMiddleware(req, res, next) { |   authMiddleware(req, res, next) { | ||||||
|  | |||||||
| @ -1,6 +1,8 @@ | |||||||
| var EventEmitter = require('events') | const Path = require('path') | ||||||
| var Logger = require('./Logger') | const EventEmitter = require('events') | ||||||
| var Watcher = require('watcher') | const Watcher = require('watcher') | ||||||
|  | const Logger = require('./Logger') | ||||||
|  | const { getIno } = require('./utils/index') | ||||||
| 
 | 
 | ||||||
| class FolderWatcher extends EventEmitter { | class FolderWatcher extends EventEmitter { | ||||||
|   constructor(audiobookPath) { |   constructor(audiobookPath) { | ||||||
| @ -8,6 +10,11 @@ class FolderWatcher extends EventEmitter { | |||||||
|     this.AudiobookPath = audiobookPath |     this.AudiobookPath = audiobookPath | ||||||
|     this.folderMap = {} |     this.folderMap = {} | ||||||
|     this.watcher = null |     this.watcher = null | ||||||
|  | 
 | ||||||
|  |     this.pendingBatchDelay = 4000 | ||||||
|  | 
 | ||||||
|  |     // Audiobook paths with changes
 | ||||||
|  |     this.pendingBatch = {} | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   initWatcher() { |   initWatcher() { | ||||||
| @ -46,32 +53,69 @@ class FolderWatcher extends EventEmitter { | |||||||
|     return this.watcher.close() |     return this.watcher.close() | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onNewFile(path) { |   // After [pendingBatchDelay] seconds emit batch
 | ||||||
|  |   async onNewFile(path) { | ||||||
|     Logger.debug('FolderWatcher: New File', path) |     Logger.debug('FolderWatcher: New File', path) | ||||||
|     this.emit('file_added', { | 
 | ||||||
|       path: path.replace(this.AudiobookPath, ''), |     var dir = Path.dirname(path) | ||||||
|       fullPath: path |     if (this.pendingBatch[dir]) { | ||||||
|     }) |       this.pendingBatch[dir].files.push(path) | ||||||
|  |       clearTimeout(this.pendingBatch[dir].timeout) | ||||||
|  |     } else { | ||||||
|  |       this.pendingBatch[dir] = { | ||||||
|  |         dir, | ||||||
|  |         files: [path] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.pendingBatch[dir].timeout = setTimeout(() => { | ||||||
|  |       this.emit('new_files', this.pendingBatch[dir]) | ||||||
|  |       delete this.pendingBatch[dir] | ||||||
|  |     }, this.pendingBatchDelay) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onFileRemoved(path) { |   onFileRemoved(path) { | ||||||
|     Logger.debug('[FolderWatcher] File Removed', path) |     Logger.debug('[FolderWatcher] File Removed', path) | ||||||
|     this.emit('file_removed', { | 
 | ||||||
|       path: path.replace(this.AudiobookPath, ''), |     var dir = Path.dirname(path) | ||||||
|       fullPath: path |     if (this.pendingBatch[dir]) { | ||||||
|     }) |       this.pendingBatch[dir].files.push(path) | ||||||
|  |       clearTimeout(this.pendingBatch[dir].timeout) | ||||||
|  |     } else { | ||||||
|  |       this.pendingBatch[dir] = { | ||||||
|  |         dir, | ||||||
|  |         files: [path] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.pendingBatch[dir].timeout = setTimeout(() => { | ||||||
|  |       this.emit('removed_files', this.pendingBatch[dir]) | ||||||
|  |       delete this.pendingBatch[dir] | ||||||
|  |     }, this.pendingBatchDelay) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onFileUpdated(path) { |   onFileUpdated(path) { | ||||||
|     Logger.debug('[FolderWatcher] Updated File', path) |     Logger.debug('[FolderWatcher] Updated File', path) | ||||||
|     this.emit('file_updated', { |  | ||||||
|       path: path.replace(this.AudiobookPath, ''), |  | ||||||
|       fullPath: path |  | ||||||
|     }) |  | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   onRename(pathFrom, pathTo) { |   onRename(pathFrom, pathTo) { | ||||||
|     Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`) |     Logger.debug(`[FolderWatcher] Rename ${pathFrom} => ${pathTo}`) | ||||||
|  | 
 | ||||||
|  |     var dir = Path.dirname(pathTo) | ||||||
|  |     if (this.pendingBatch[dir]) { | ||||||
|  |       this.pendingBatch[dir].files.push(pathTo) | ||||||
|  |       clearTimeout(this.pendingBatch[dir].timeout) | ||||||
|  |     } else { | ||||||
|  |       this.pendingBatch[dir] = { | ||||||
|  |         dir, | ||||||
|  |         files: [pathTo] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     this.pendingBatch[dir].timeout = setTimeout(() => { | ||||||
|  |       this.emit('renamed_files', this.pendingBatch[dir]) | ||||||
|  |       delete this.pendingBatch[dir] | ||||||
|  |     }, this.pendingBatchDelay) | ||||||
|   } |   } | ||||||
| } | } | ||||||
| module.exports = FolderWatcher | module.exports = FolderWatcher | ||||||
							
								
								
									
										7
									
								
								server/utils/constants.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								server/utils/constants.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,7 @@ | |||||||
|  | module.exports.ScanResult = { | ||||||
|  |   NOTHING: 0, | ||||||
|  |   ADDED: 1, | ||||||
|  |   UPDATED: 2, | ||||||
|  |   REMOVED: 3, | ||||||
|  |   UPTODATE: 4 | ||||||
|  | } | ||||||
| @ -32,17 +32,20 @@ module.exports.levenshteinDistance = levenshteinDistance | |||||||
| const cleanString = (str) => { | const cleanString = (str) => { | ||||||
|   if (!str) return '' |   if (!str) return '' | ||||||
| 
 | 
 | ||||||
|  |   // Now supporting all utf-8 characters, can remove this method in future
 | ||||||
|  | 
 | ||||||
|   // replace accented characters: https://stackoverflow.com/a/49901740/7431543
 |   // replace accented characters: https://stackoverflow.com/a/49901740/7431543
 | ||||||
|   str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "") |   // str = str.normalize('NFD').replace(/[\u0300-\u036f]/g, "")
 | ||||||
| 
 | 
 | ||||||
|   const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~" |   // const availableChars = " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~"
 | ||||||
|   const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char |   // const cleanChar = (char) => availableChars.indexOf(char) < 0 ? '?' : char
 | ||||||
| 
 | 
 | ||||||
|   var cleaned = '' |   // var cleaned = ''
 | ||||||
|   for (let i = 0; i < str.length; i++) { |   // for (let i = 0; i < str.length; i++) {
 | ||||||
|     cleaned += cleanChar(str[i]) |   //   cleaned += cleanChar(str[i])
 | ||||||
|   } |   // }
 | ||||||
|   return cleaned | 
 | ||||||
|  |   return cleaned.trim() | ||||||
| } | } | ||||||
| module.exports.cleanString = cleanString | module.exports.cleanString = cleanString | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -30,7 +30,54 @@ function getFileType(ext) { | |||||||
|   return 'unknown' |   return 'unknown' | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| async function getAllAudiobookFiles(abRootPath, serverSettings = {}) { | // Input relative filepath, output all details that can be parsed
 | ||||||
|  | function getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle = false) { | ||||||
|  |   var pathformat = Path.parse(relpath) | ||||||
|  |   var path = pathformat.dir | ||||||
|  | 
 | ||||||
|  |   if (!path) { | ||||||
|  |     Logger.error('Ignoring file in root dir', relpath) | ||||||
|  |     return null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   // If relative file directory has 3 folders, then the middle folder will be series
 | ||||||
|  |   var splitDir = path.split(Path.sep) | ||||||
|  |   var author = null | ||||||
|  |   if (splitDir.length > 1) author = splitDir.shift() | ||||||
|  |   var series = null | ||||||
|  |   if (splitDir.length > 1) series = splitDir.shift() | ||||||
|  |   var title = splitDir.shift() | ||||||
|  | 
 | ||||||
|  |   var publishYear = null | ||||||
|  |   var subtitle = null | ||||||
|  | 
 | ||||||
|  |   // If Title is of format 1999 - Title, then use 1999 as publish year
 | ||||||
|  |   var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/) | ||||||
|  |   if (publishYearMatch && publishYearMatch.length > 2) { | ||||||
|  |     if (!isNaN(publishYearMatch[1])) { | ||||||
|  |       publishYear = publishYearMatch[1] | ||||||
|  |       title = publishYearMatch[2] | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   if (parseSubtitle && title.includes(' - ')) { | ||||||
|  |     var splitOnSubtitle = title.split(' - ') | ||||||
|  |     title = splitOnSubtitle.shift() | ||||||
|  |     subtitle = splitOnSubtitle.join(' - ') | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   return { | ||||||
|  |     author, | ||||||
|  |     title, | ||||||
|  |     subtitle, | ||||||
|  |     series, | ||||||
|  |     publishYear, | ||||||
|  |     path, // relative audiobook path i.e. /Author Name/Book Name/..
 | ||||||
|  |     fullPath: Path.join(abRootPath, path) // i.e. /audiobook/Author Name/Book Name/..
 | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | async function getAllAudiobookFileData(abRootPath, serverSettings = {}) { | ||||||
|   var parseSubtitle = !!serverSettings.scannerParseSubtitle |   var parseSubtitle = !!serverSettings.scannerParseSubtitle | ||||||
| 
 | 
 | ||||||
|   var paths = await getPaths(abRootPath) |   var paths = await getPaths(abRootPath) | ||||||
| @ -38,59 +85,26 @@ async function getAllAudiobookFiles(abRootPath, serverSettings = {}) { | |||||||
| 
 | 
 | ||||||
|   paths.files.forEach((filepath) => { |   paths.files.forEach((filepath) => { | ||||||
|     var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) |     var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) | ||||||
|     var pathformat = Path.parse(relpath) |     var parsed = Path.parse(relpath) | ||||||
|     var path = pathformat.dir |     var path = parsed.dir | ||||||
| 
 |  | ||||||
|     if (!path) { |  | ||||||
|       Logger.error('Ignoring file in root dir', filepath) |  | ||||||
|       return |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     // If relative file directory has 3 folders, then the middle folder will be series
 |  | ||||||
|     var splitDir = pathformat.dir.split(Path.sep) |  | ||||||
|     var author = null |  | ||||||
|     if (splitDir.length > 1) author = splitDir.shift() |  | ||||||
|     var series = null |  | ||||||
|     if (splitDir.length > 1) series = splitDir.shift() |  | ||||||
|     var title = splitDir.shift() |  | ||||||
| 
 |  | ||||||
|     var publishYear = null |  | ||||||
|     var subtitle = null |  | ||||||
| 
 |  | ||||||
|     // If Title is of format 1999 - Title, then use 1999 as publish year
 |  | ||||||
|     var publishYearMatch = title.match(/^([0-9]{4}) - (.+)/) |  | ||||||
|     if (publishYearMatch && publishYearMatch.length > 2) { |  | ||||||
|       if (!isNaN(publishYearMatch[1])) { |  | ||||||
|         publishYear = publishYearMatch[1] |  | ||||||
|         title = publishYearMatch[2] |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     if (parseSubtitle && title.includes(' - ')) { |  | ||||||
|       var splitOnSubtitle = title.split(' - ') |  | ||||||
|       title = splitOnSubtitle.shift() |  | ||||||
|       subtitle = splitOnSubtitle.join(' - ') |  | ||||||
|     } |  | ||||||
| 
 | 
 | ||||||
|     if (!audiobooks[path]) { |     if (!audiobooks[path]) { | ||||||
|  |       var audiobookData = getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle) | ||||||
|  |       if (!audiobookData) return | ||||||
|  | 
 | ||||||
|       audiobooks[path] = { |       audiobooks[path] = { | ||||||
|         author, |         ...audiobookData, | ||||||
|         title, |  | ||||||
|         subtitle, |  | ||||||
|         series: cleanString(series), |  | ||||||
|         publishYear: publishYear, |  | ||||||
|         path: path, |  | ||||||
|         fullPath: Path.join(abRootPath, path), |  | ||||||
|         audioFiles: [], |         audioFiles: [], | ||||||
|         otherFiles: [] |         otherFiles: [] | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  | 
 | ||||||
|     var fileObj = { |     var fileObj = { | ||||||
|       filetype: getFileType(pathformat.ext), |       filetype: getFileType(parsed.ext), | ||||||
|       filename: pathformat.base, |       filename: parsed.base, | ||||||
|       path: relpath, |       path: relpath, | ||||||
|       fullPath: filepath, |       fullPath: filepath, | ||||||
|       ext: pathformat.ext |       ext: parsed.ext | ||||||
|     } |     } | ||||||
|     if (fileObj.filetype === 'audio') { |     if (fileObj.filetype === 'audio') { | ||||||
|       audiobooks[path].audioFiles.push(fileObj) |       audiobooks[path].audioFiles.push(fileObj) | ||||||
| @ -100,4 +114,44 @@ async function getAllAudiobookFiles(abRootPath, serverSettings = {}) { | |||||||
|   }) |   }) | ||||||
|   return Object.values(audiobooks) |   return Object.values(audiobooks) | ||||||
| } | } | ||||||
| module.exports.getAllAudiobookFiles = getAllAudiobookFiles | module.exports.getAllAudiobookFileData = getAllAudiobookFileData | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | async function getAudiobookFileData(abRootPath, audiobookPath, serverSettings = {}) { | ||||||
|  |   var parseSubtitle = !!serverSettings.scannerParseSubtitle | ||||||
|  | 
 | ||||||
|  |   var paths = await getPaths(audiobookPath) | ||||||
|  |   var audiobook = null | ||||||
|  | 
 | ||||||
|  |   paths.files.forEach((filepath) => { | ||||||
|  |     var relpath = Path.normalize(filepath).replace(abRootPath, '').slice(1) | ||||||
|  | 
 | ||||||
|  |     if (!audiobook) { | ||||||
|  |       var audiobookData = getAudiobookDataFromFilepath(abRootPath, relpath, parseSubtitle) | ||||||
|  |       if (!audiobookData) return | ||||||
|  | 
 | ||||||
|  |       audiobook = { | ||||||
|  |         ...audiobookData, | ||||||
|  |         audioFiles: [], | ||||||
|  |         otherFiles: [] | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     var extname = Path.extname(filepath) | ||||||
|  |     var basename = Path.basename(filepath) | ||||||
|  |     var fileObj = { | ||||||
|  |       filetype: getFileType(extname), | ||||||
|  |       filename: basename, | ||||||
|  |       path: relpath, | ||||||
|  |       fullPath: filepath, | ||||||
|  |       ext: extname | ||||||
|  |     } | ||||||
|  |     if (fileObj.filetype === 'audio') { | ||||||
|  |       audiobook.audioFiles.push(fileObj) | ||||||
|  |     } else { | ||||||
|  |       audiobook.otherFiles.push(fileObj) | ||||||
|  |     } | ||||||
|  |   }) | ||||||
|  |   return audiobook | ||||||
|  | } | ||||||
|  | module.exports.getAudiobookFileData = getAudiobookFileData | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user