mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Updates to new library scanner and adding jsdoc types
This commit is contained in:
		
							parent
							
								
									ea1d051cfb
								
							
						
					
					
						commit
						2c8448d147
					
				| @ -16,11 +16,10 @@ const Logger = require('./Logger') | ||||
| const Auth = require('./Auth') | ||||
| const Watcher = require('./Watcher') | ||||
| const Scanner = require('./scanner/Scanner') | ||||
| const LibraryScanner = require('./scanner/LibraryScanner') | ||||
| const Database = require('./Database') | ||||
| const SocketAuthority = require('./SocketAuthority') | ||||
| 
 | ||||
| const routes = require('./routes/index') | ||||
| 
 | ||||
| const ApiRouter = require('./routers/ApiRouter') | ||||
| const HlsRouter = require('./routers/HlsRouter') | ||||
| 
 | ||||
| @ -78,6 +77,7 @@ class Server { | ||||
|     this.rssFeedManager = new RssFeedManager() | ||||
| 
 | ||||
|     this.scanner = new Scanner(this.coverManager, this.taskManager) | ||||
|     this.libraryScanner = new LibraryScanner(this.coverManager, this.taskManager) | ||||
|     this.cronManager = new CronManager(this.scanner, this.podcastManager) | ||||
| 
 | ||||
|     // Routers
 | ||||
|  | ||||
| @ -977,6 +977,8 @@ class LibraryController { | ||||
|     } | ||||
|     res.sendStatus(200) | ||||
|     await this.scanner.scan(req.library, options) | ||||
|     // TODO: New library scanner
 | ||||
|     // await this.libraryScanner.scan(req.library, options)
 | ||||
|     await Database.resetLibraryIssuesFilterData(req.library.id) | ||||
|     Logger.info('[LibraryController] Scan complete') | ||||
|   } | ||||
|  | ||||
| @ -18,6 +18,31 @@ const Logger = require('../Logger') | ||||
|  * @property {string} title | ||||
|  */ | ||||
| 
 | ||||
| /** | ||||
|  * @typedef AudioFileObject | ||||
|  * @property {number} index | ||||
|  * @property {string} ino | ||||
|  * @property {{filename:string, ext:string, path:string, relPath:string, size:number, mtimeMs:number, ctimeMs:number, birthtimeMs:number}} metadata | ||||
|  * @property {number} addedAt | ||||
|  * @property {number} updatedAt | ||||
|  * @property {number} trackNumFromMeta | ||||
|  * @property {number} discNumFromMeta | ||||
|  * @property {number} trackNumFromFilename | ||||
|  * @property {number} discNumFromFilename | ||||
|  * @property {boolean} manuallyVerified | ||||
|  * @property {string} format | ||||
|  * @property {number} duration | ||||
|  * @property {number} bitRate | ||||
|  * @property {string} language | ||||
|  * @property {string} codec | ||||
|  * @property {string} timeBase | ||||
|  * @property {number} channels | ||||
|  * @property {string} channelLayout | ||||
|  * @property {ChapterObject[]} chapters | ||||
|  * @property {Object} metaTags | ||||
|  * @property {string} mimeType | ||||
|  */ | ||||
| 
 | ||||
| class Book extends Model { | ||||
|   constructor(values, options) { | ||||
|     super(values, options) | ||||
| @ -52,7 +77,7 @@ class Book extends Model { | ||||
|     this.duration | ||||
|     /** @type {string[]} */ | ||||
|     this.narrators | ||||
|     /** @type {Object} */ | ||||
|     /** @type {AudioFileObject[]} */ | ||||
|     this.audioFiles | ||||
|     /** @type {EBookFileObject} */ | ||||
|     this.ebookFile | ||||
|  | ||||
| @ -3,6 +3,8 @@ const Logger = require('../Logger') | ||||
| const oldLibraryItem = require('../objects/LibraryItem') | ||||
| const libraryFilters = require('../utils/queries/libraryFilters') | ||||
| const { areEquivalent } = require('../utils/index') | ||||
| const Book = require('./Book') | ||||
| const Podcast = require('./Podcast') | ||||
| 
 | ||||
| /** | ||||
|  * @typedef LibraryFileObject | ||||
| @ -791,6 +793,11 @@ class LibraryItem extends Model { | ||||
|     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)}` | ||||
|  | ||||
| @ -64,7 +64,7 @@ class AudioFile { | ||||
|       channelLayout: this.channelLayout, | ||||
|       chapters: this.chapters, | ||||
|       embeddedCoverArt: this.embeddedCoverArt, | ||||
|       metaTags: this.metaTags ? this.metaTags.toJSON() : {}, | ||||
|       metaTags: this.metaTags?.toJSON() || {}, | ||||
|       mimeType: this.mimeType | ||||
|     } | ||||
|   } | ||||
| @ -163,11 +163,16 @@ class AudioFile { | ||||
|     return new AudioFile(this.toJSON()) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    *  | ||||
|    * @param {AudioFile} scannedAudioFile  | ||||
|    * @returns {boolean} true if updates were made | ||||
|    */ | ||||
|   updateFromScan(scannedAudioFile) { | ||||
|     let hasUpdated = false | ||||
| 
 | ||||
|     const newjson = scannedAudioFile.toJSON() | ||||
|     const ignoreKeys = ['manuallyVerified', 'exclude', 'addedAt', 'updatedAt'] | ||||
|     const ignoreKeys = ['manuallyVerified', 'ctimeMs', 'addedAt', 'updatedAt'] | ||||
| 
 | ||||
|     for (const key in newjson) { | ||||
|       if (key === 'metadata') { | ||||
|  | ||||
| @ -40,6 +40,7 @@ class ApiRouter { | ||||
|   constructor(Server) { | ||||
|     this.auth = Server.auth | ||||
|     this.scanner = Server.scanner | ||||
|     this.libraryScanner = Server.libraryScanner | ||||
|     this.playbackSessionManager = Server.playbackSessionManager | ||||
|     this.abMergeManager = Server.abMergeManager | ||||
|     this.backupManager = Server.backupManager | ||||
|  | ||||
| @ -1,6 +1,197 @@ | ||||
| const Path = require('path') | ||||
| const Logger = require('../Logger') | ||||
| const prober = require('../utils/prober') | ||||
| const LibraryItem = require('../models/LibraryItem') | ||||
| const AudioFile = require('../objects/files/AudioFile') | ||||
| 
 | ||||
| class AudioFileScanner { | ||||
|   constructor() { } | ||||
| 
 | ||||
|   /** | ||||
|    * Is array of numbers sequential, i.e. 1, 2, 3, 4 | ||||
|    * @param {number[]} nums  | ||||
|    * @returns {boolean} | ||||
|    */ | ||||
|   isSequential(nums) { | ||||
|     if (!nums?.length) return false | ||||
|     if (nums.length === 1) return true | ||||
|     let prev = nums[0] | ||||
|     for (let i = 1; i < nums.length; i++) { | ||||
|       if (nums[i] - prev > 1) return false | ||||
|       prev = nums[i] | ||||
|     } | ||||
|     return true | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Remove  | ||||
|    * @param {number[]} nums  | ||||
|    * @returns {number[]} | ||||
|    */ | ||||
|   removeDupes(nums) { | ||||
|     if (!nums || !nums.length) return [] | ||||
|     if (nums.length === 1) return nums | ||||
| 
 | ||||
|     let nodupes = [nums[0]] | ||||
|     nums.forEach((num) => { | ||||
|       if (num > nodupes[nodupes.length - 1]) nodupes.push(num) | ||||
|     }) | ||||
|     return nodupes | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Order audio files by track/disc number | ||||
|    * @param {import('../models/Book')} book  | ||||
|    * @param {import('../models/Book').AudioFileObject[]} audioFiles  | ||||
|    * @returns {import('../models/Book').AudioFileObject[]} | ||||
|    */ | ||||
|   runSmartTrackOrder(book, audioFiles) { | ||||
|     let discsFromFilename = [] | ||||
|     let tracksFromFilename = [] | ||||
|     let discsFromMeta = [] | ||||
|     let tracksFromMeta = [] | ||||
| 
 | ||||
|     audioFiles.forEach((af) => { | ||||
|       if (af.discNumFromFilename !== null) discsFromFilename.push(af.discNumFromFilename) | ||||
|       if (af.discNumFromMeta !== null) discsFromMeta.push(af.discNumFromMeta) | ||||
|       if (af.trackNumFromFilename !== null) tracksFromFilename.push(af.trackNumFromFilename) | ||||
|       if (af.trackNumFromMeta !== null) tracksFromMeta.push(af.trackNumFromMeta) | ||||
|     }) | ||||
|     discsFromFilename.sort((a, b) => a - b) | ||||
|     discsFromMeta.sort((a, b) => a - b) | ||||
|     tracksFromFilename.sort((a, b) => a - b) | ||||
|     tracksFromMeta.sort((a, b) => a - b) | ||||
| 
 | ||||
|     let discKey = null | ||||
|     if (discsFromMeta.length === audioFiles.length && this.isSequential(discsFromMeta)) { | ||||
|       discKey = 'discNumFromMeta' | ||||
|     } else if (discsFromFilename.length === audioFiles.length && this.isSequential(discsFromFilename)) { | ||||
|       discKey = 'discNumFromFilename' | ||||
|     } | ||||
| 
 | ||||
|     let trackKey = null | ||||
|     tracksFromFilename = this.removeDupes(tracksFromFilename) | ||||
|     tracksFromMeta = this.removeDupes(tracksFromMeta) | ||||
|     if (tracksFromFilename.length > tracksFromMeta.length) { | ||||
|       trackKey = 'trackNumFromFilename' | ||||
|     } else { | ||||
|       trackKey = 'trackNumFromMeta' | ||||
|     } | ||||
| 
 | ||||
|     if (discKey !== null) { | ||||
|       Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using disc key ${discKey} and track key ${trackKey}`) | ||||
|       audioFiles.sort((a, b) => { | ||||
|         let Dx = a[discKey] - b[discKey] | ||||
|         if (Dx === 0) Dx = a[trackKey] - b[trackKey] | ||||
|         return Dx | ||||
|       }) | ||||
|     } else { | ||||
|       Logger.debug(`[AudioFileScanner] Smart track order for "${book.title}" using track key ${trackKey}`) | ||||
|       audioFiles.sort((a, b) => a[trackKey] - b[trackKey]) | ||||
|     } | ||||
| 
 | ||||
|     for (let i = 0; i < audioFiles.length; i++) { | ||||
|       audioFiles[i].index = i + 1 | ||||
|     } | ||||
|     return audioFiles | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Get track and disc number from audio filename | ||||
|    * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan  | ||||
|    * @param {LibraryItem.LibraryFileObject} audioLibraryFile  | ||||
|    * @returns {{trackNumber:number, discNumber:number}} | ||||
|    */ | ||||
|   getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, audioLibraryFile) { | ||||
|     const { title, author, series, publishedYear } = mediaMetadataFromScan | ||||
|     const { filename, path } = audioLibraryFile.metadata | ||||
|     let partbasename = Path.basename(filename, Path.extname(filename)) | ||||
| 
 | ||||
|     // Remove title, author, series, and publishedYear from filename if there
 | ||||
|     if (title) partbasename = partbasename.replace(title, '') | ||||
|     if (author) partbasename = partbasename.replace(author, '') | ||||
|     if (series) partbasename = partbasename.replace(series, '') | ||||
|     if (publishedYear) partbasename = partbasename.replace(publishedYear) | ||||
| 
 | ||||
|     // Look for disc number
 | ||||
|     let discNumber = null | ||||
|     const discMatch = partbasename.match(/\b(disc|cd) ?(\d\d?)\b/i) | ||||
|     if (discMatch && discMatch.length > 2 && discMatch[2]) { | ||||
|       if (!isNaN(discMatch[2])) { | ||||
|         discNumber = Number(discMatch[2]) | ||||
|       } | ||||
| 
 | ||||
|       // Remove disc number from filename
 | ||||
|       partbasename = partbasename.replace(/\b(disc|cd) ?(\d\d?)\b/i, '') | ||||
|     } | ||||
| 
 | ||||
|     // Look for disc number in folder path e.g. /Book Title/CD01/audiofile.mp3
 | ||||
|     const pathdir = Path.dirname(path).split('/').pop() | ||||
|     if (pathdir && /^cd\d{1,3}$/i.test(pathdir)) { | ||||
|       const discFromFolder = Number(pathdir.replace(/cd/i, '')) | ||||
|       if (!isNaN(discFromFolder) && discFromFolder !== null) discNumber = discFromFolder | ||||
|     } | ||||
| 
 | ||||
|     const numbersinpath = partbasename.match(/\d{1,4}/g) | ||||
|     const trackNumber = numbersinpath && numbersinpath.length ? parseInt(numbersinpath[0]) : null | ||||
|     return { | ||||
|       trackNumber, | ||||
|       discNumber | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    *  | ||||
|    * @param {string} mediaType  | ||||
|    * @param {LibraryItem.LibraryFileObject} libraryFile  | ||||
|    * @param {{title:string, subtitle:string, series:string, sequence:string, publishedYear:string, narrators:string}} mediaMetadataFromScan  | ||||
|    * @returns {Promise<AudioFile>} | ||||
|    */ | ||||
|   async scan(mediaType, libraryFile, mediaMetadataFromScan) { | ||||
|     const probeData = await prober.probe(libraryFile.metadata.path) | ||||
| 
 | ||||
|     if (probeData.error) { | ||||
|       Logger.error(`[MediaFileScanner] ${probeData.error} : "${libraryFile.metadata.path}"`) | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     if (!probeData.audioStream) { | ||||
|       Logger.error('[MediaFileScanner] Invalid audio file no audio stream') | ||||
|       return null | ||||
|     } | ||||
| 
 | ||||
|     const audioFile = new AudioFile() | ||||
|     audioFile.trackNumFromMeta = probeData.audioMetaTags.trackNumber | ||||
|     audioFile.discNumFromMeta = probeData.audioMetaTags.discNumber | ||||
|     if (mediaType === 'book') { | ||||
|       const { trackNumber, discNumber } = this.getTrackAndDiscNumberFromFilename(mediaMetadataFromScan, libraryFile) | ||||
|       audioFile.trackNumFromFilename = trackNumber | ||||
|       audioFile.discNumFromFilename = discNumber | ||||
|     } | ||||
|     audioFile.setDataFromProbe(libraryFile, probeData) | ||||
| 
 | ||||
|     return audioFile | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Scan LibraryFiles and return AudioFiles | ||||
|    * @param {string} mediaType | ||||
|    * @param {import('./LibraryItemScanData')} libraryItemScanData  | ||||
|    * @param {LibraryItem.LibraryFileObject[]} audioLibraryFiles | ||||
|    * @returns {Promise<AudioFile[]>} | ||||
|    */ | ||||
|   async executeMediaFileScans(mediaType, libraryItemScanData, audioLibraryFiles) { | ||||
|     const batchSize = 32 | ||||
|     const results = [] | ||||
|     for (let batch = 0; batch < audioLibraryFiles.length; batch += batchSize) { | ||||
|       const proms = [] | ||||
|       for (let i = batch; i < Math.min(batch + batchSize, audioLibraryFiles.length); i++) { | ||||
|         proms.push(this.scan(mediaType, audioLibraryFiles[i], libraryItemScanData.mediaMetadata)) | ||||
|       } | ||||
|       results.push(...await Promise.all(proms).then((scanResults) => scanResults.filter(sr => sr))) | ||||
|     } | ||||
| 
 | ||||
|     return results | ||||
|   } | ||||
| } | ||||
| module.exports = new AudioFileScanner() | ||||
| @ -1,6 +1,7 @@ | ||||
| const packageJson = require('../../package.json') | ||||
| const { LogLevel } = require('../utils/constants') | ||||
| const LibraryItem = require('../models/LibraryItem') | ||||
| const globals = require('../utils/globals') | ||||
| 
 | ||||
| class LibraryItemScanData { | ||||
|   constructor(data) { | ||||
| @ -33,11 +34,41 @@ class LibraryItemScanData { | ||||
|     /** @type {boolean} */ | ||||
|     this.hasPathChange | ||||
|     /** @type {LibraryItem.LibraryFileObject[]} */ | ||||
|     this.libraryFilesRemoved | ||||
|     this.libraryFilesRemoved = [] | ||||
|     /** @type {LibraryItem.LibraryFileObject[]} */ | ||||
|     this.libraryFilesAdded | ||||
|     this.libraryFilesAdded = [] | ||||
|     /** @type {LibraryItem.LibraryFileObject[]} */ | ||||
|     this.libraryFilesModified | ||||
|     this.libraryFilesModified = [] | ||||
|   } | ||||
| 
 | ||||
|   /** @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 | ||||
|   } | ||||
| 
 | ||||
|   /** @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() || '')) | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -46,7 +77,7 @@ class LibraryItemScanData { | ||||
|    * @param {import('./LibraryScan')} libraryScan | ||||
|    */ | ||||
|   async checkLibraryItemData(existingLibraryItem, libraryScan) { | ||||
|     const keysToCompare = ['libraryFolderId', 'ino', 'mtimeMs', 'ctimeMs', 'birthtimeMs', 'path', 'relPath', 'isFile'] | ||||
|     const keysToCompare = ['libraryFolderId', 'ino', 'path', 'relPath', 'isFile'] | ||||
|     this.hasChanges = false | ||||
|     this.hasPathChange = false | ||||
|     for (const key of keysToCompare) { | ||||
| @ -61,6 +92,23 @@ class LibraryItemScanData { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     // 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 | ||||
|     } | ||||
| 
 | ||||
|     this.libraryFilesRemoved = [] | ||||
|     this.libraryFilesModified = [] | ||||
|     let libraryFilesAdded = this.libraryFiles.map(lf => lf) | ||||
| @ -98,15 +146,24 @@ class LibraryItemScanData { | ||||
|       } | ||||
|     } | ||||
| 
 | ||||
|     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.path}" changed: [${existingLibraryItem.changed()?.join(',') || ''}]`) | ||||
| 
 | ||||
|       if (this.hasLibraryFileChanges) { | ||||
|         existingLibraryItem.changed('libraryFiles', true) | ||||
|       } | ||||
|       await existingLibraryItem.save() | ||||
|     } else { | ||||
|       libraryScan.addLog(LogLevel.DEBUG, `Library item "${existingLibraryItem.path}" is up-to-date`) | ||||
|     } | ||||
| 
 | ||||
|     this.libraryFilesAdded = libraryFilesAdded | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
| @ -126,6 +183,10 @@ class LibraryItemScanData { | ||||
|     } | ||||
| 
 | ||||
|     for (const key in existingLibraryFile.metadata) { | ||||
|       if (existingLibraryFile.metadata.relPath === 'metadata.json' || existingLibraryFile.metadata.relPath === 'metadata.abs') { | ||||
|         if (key === 'mtimeMs' || key === 'size') continue | ||||
|       } | ||||
| 
 | ||||
|       if (existingLibraryFile.metadata[key] !== scannedLibraryFile.metadata[key]) { | ||||
|         if (key !== 'path' && key !== 'relPath') { | ||||
|           libraryScan.addLog(LogLevel.DEBUG, `Library file "${existingLibraryFile.metadata.path}" for library item "${libraryItemPath}" key "${key}" changed from "${existingLibraryFile.metadata[key]}" to "${scannedLibraryFile.metadata[key]}"`) | ||||
| @ -143,5 +204,20 @@ class LibraryItemScanData { | ||||
| 
 | ||||
|     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) | ||||
|   } | ||||
| } | ||||
| module.exports = LibraryItemScanData | ||||
| @ -7,7 +7,12 @@ const fs = require('../libs/fsExtra') | ||||
| const fileUtils = require('../utils/fileUtils') | ||||
| const scanUtils = require('../utils/scandir') | ||||
| const { ScanResult, LogLevel } = require('../utils/constants') | ||||
| const AudioFileScanner = require('./AudioFileScanner') | ||||
| const ScanOptions = require('./ScanOptions') | ||||
| const LibraryScan = require('./LibraryScan') | ||||
| const LibraryItemScanData = require('./LibraryItemScanData') | ||||
| const AudioFile = require('../objects/files/AudioFile') | ||||
| const Book = require('../models/Book') | ||||
| 
 | ||||
| class LibraryScanner { | ||||
|   constructor(coverManager, taskManager) { | ||||
| @ -102,7 +107,7 @@ class LibraryScanner { | ||||
|       where: { | ||||
|         libraryId: libraryScan.libraryId | ||||
|       }, | ||||
|       attributes: ['id', 'mediaId', 'mediaType', 'path', 'relPath', 'ino', 'isMissing', 'mtime', 'ctime', 'birthtime', 'libraryFiles', 'libraryFolderId'] | ||||
|       attributes: ['id', 'mediaId', 'mediaType', 'path', 'relPath', 'ino', 'isMissing', 'isFile', 'mtime', 'ctime', 'birthtime', 'libraryFiles', 'libraryFolderId', 'size'] | ||||
|     }) | ||||
| 
 | ||||
|     const libraryItemIdsMissing = [] | ||||
| @ -129,8 +134,8 @@ class LibraryScanner { | ||||
|         } | ||||
|       } else { | ||||
|         await libraryItemData.checkLibraryItemData(existingLibraryItem, libraryScan) | ||||
|         if (libraryItemData.hasChanges) { | ||||
|           await this.rescanLibraryItem(existingLibraryItem, libraryItemData) | ||||
|         if (libraryItemData.hasLibraryFileChanges || libraryItemData.hasPathChange) { | ||||
|           await this.rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) | ||||
|         } | ||||
|       } | ||||
|     } | ||||
| @ -222,9 +227,92 @@ class LibraryScanner { | ||||
|    *  | ||||
|    * @param {import('../models/LibraryItem')} existingLibraryItem  | ||||
|    * @param {LibraryItemScanData} libraryItemData  | ||||
|    * @param {LibraryScan} libraryScan | ||||
|    */ | ||||
|   async rescanLibraryItem(existingLibraryItem, libraryItemData) { | ||||
|   async rescanLibraryItem(existingLibraryItem, libraryItemData, libraryScan) { | ||||
| 
 | ||||
|     if (existingLibraryItem.mediaType === 'book') { | ||||
|       /** @type {Book} */ | ||||
|       const media = await existingLibraryItem.getMedia({ | ||||
|         include: [ | ||||
|           { | ||||
|             model: Database.authorModel, | ||||
|             through: { | ||||
|               attributes: ['createdAt'] | ||||
|             } | ||||
|           }, | ||||
|           { | ||||
|             model: Database.seriesModel, | ||||
|             through: { | ||||
|               attributes: ['sequence', 'createdAt'] | ||||
|             } | ||||
|           } | ||||
|         ] | ||||
|       }) | ||||
| 
 | ||||
|       let hasMediaChanges = libraryItemData.hasAudioFileChanges | ||||
|       if (libraryItemData.hasAudioFileChanges || libraryItemData.audioLibraryFiles.length !== media.audioFiles.length) { | ||||
|         // Filter out audio files that were removed
 | ||||
|         media.audioFiles = media.audioFiles.filter(af => libraryItemData.checkAudioFileRemoved(af)) | ||||
| 
 | ||||
|         // Update audio files that were modified
 | ||||
|         if (libraryItemData.audioLibraryFilesModified.length) { | ||||
|           let scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesModified) | ||||
|           media.audioFiles = media.audioFiles.map((audioFileObj) => { | ||||
|             let matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.metadata.path === audioFileObj.metadata.path) | ||||
|             if (!matchedScannedAudioFile) { | ||||
|               matchedScannedAudioFile = scannedAudioFiles.find(saf => saf.ino === audioFileObj.ino) | ||||
|             } | ||||
| 
 | ||||
|             if (matchedScannedAudioFile) { | ||||
|               scannedAudioFiles = scannedAudioFiles.filter(saf => saf !== matchedScannedAudioFile) | ||||
|               const audioFile = new AudioFile(audioFileObj) | ||||
|               audioFile.updateFromScan(matchedScannedAudioFile) | ||||
|               return audioFile.toJSON() | ||||
|             } | ||||
|             return audioFileObj | ||||
|           }) | ||||
|           // Modified audio files that were not found on the book
 | ||||
|           if (scannedAudioFiles.length) { | ||||
|             media.audioFiles.push(...scannedAudioFiles) | ||||
|           } | ||||
|         } | ||||
| 
 | ||||
|         // Add new audio files scanned in
 | ||||
|         if (libraryItemData.audioLibraryFilesAdded.length) { | ||||
|           const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, libraryItemData.audioLibraryFilesAdded) | ||||
|           media.audioFiles.push(...scannedAudioFiles) | ||||
|         } | ||||
| 
 | ||||
|         // Add audio library files that are not already set on the book (safety check)
 | ||||
|         let audioLibraryFilesToAdd = [] | ||||
|         for (const audioLibraryFile of libraryItemData.audioLibraryFiles) { | ||||
|           if (!media.audioFiles.some(af => af.ino === audioLibraryFile.ino)) { | ||||
|             libraryScan.addLog(LogLevel.DEBUG, `Existing audio library file "${audioLibraryFile.metadata.relPath}" was not set on book "${media.title}" so setting it now`) | ||||
|             audioLibraryFilesToAdd.push(audioLibraryFile) | ||||
|           } | ||||
|         } | ||||
|         if (audioLibraryFilesToAdd.length) { | ||||
|           const scannedAudioFiles = await AudioFileScanner.executeMediaFileScans(existingLibraryItem.mediaType, libraryItemData, audioLibraryFilesToAdd) | ||||
|           media.audioFiles.push(...scannedAudioFiles) | ||||
|         } | ||||
| 
 | ||||
|         media.audioFiles = AudioFileScanner.runSmartTrackOrder(media, media.audioFiles) | ||||
| 
 | ||||
|         media.duration = 0 | ||||
|         media.audioFiles.forEach((af) => { | ||||
|           if (!isNaN(af.duration)) { | ||||
|             media.duration += af.duration | ||||
|           } | ||||
|         }) | ||||
| 
 | ||||
|         media.changed('audioFiles', true) | ||||
|       } | ||||
| 
 | ||||
|       if (hasMediaChanges) { | ||||
|         await media.save() | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
| module.exports = LibraryScanner | ||||
| @ -278,7 +278,12 @@ function parseProbeData(data, verbose = false) { | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| // Updated probe returns MediaProbeData object
 | ||||
| /** | ||||
|  * Run ffprobe on audio filepath | ||||
|  * @param {string} filepath  | ||||
|  * @param {boolean} [verbose=false]  | ||||
|  * @returns {import('../scanner/MediaProbeData')|{error:string}} | ||||
|  */ | ||||
| function probe(filepath, verbose = false) { | ||||
|   if (process.env.FFPROBE_PATH) { | ||||
|     ffprobe.FFPROBE_PATH = process.env.FFPROBE_PATH | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user