mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-12-20 19:06:06 +01:00
777 lines
31 KiB
JavaScript
777 lines
31 KiB
JavaScript
const fs = require('fs-extra')
|
|
const Path = require('path')
|
|
|
|
// Utils
|
|
const Logger = require('../Logger')
|
|
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
|
|
const { comparePaths } = require('../utils/index')
|
|
const { ScanResult, LogLevel } = require('../utils/constants')
|
|
|
|
const AudioFileScanner = require('./AudioFileScanner')
|
|
const BookFinder = require('../finders/BookFinder')
|
|
const LibraryItem = require('../objects/LibraryItem')
|
|
const LibraryScan = require('./LibraryScan')
|
|
const ScanOptions = require('./ScanOptions')
|
|
|
|
const Author = require('../objects/entities/Author')
|
|
const Series = require('../objects/entities/Series')
|
|
|
|
class Scanner {
|
|
constructor(db, coverManager, emitter) {
|
|
this.ScanLogPath = Path.posix.join(global.MetadataPath, 'logs', 'scans')
|
|
|
|
this.db = db
|
|
this.coverManager = coverManager
|
|
this.emitter = emitter
|
|
|
|
this.cancelLibraryScan = {}
|
|
this.librariesScanning = []
|
|
|
|
this.bookFinder = new BookFinder()
|
|
}
|
|
|
|
isLibraryScanning(libraryId) {
|
|
return this.librariesScanning.find(ls => ls.id === libraryId)
|
|
}
|
|
|
|
setCancelLibraryScan(libraryId) {
|
|
var libraryScanning = this.librariesScanning.find(ls => ls.id === libraryId)
|
|
if (!libraryScanning) return
|
|
this.cancelLibraryScan[libraryId] = true
|
|
}
|
|
|
|
async scanLibraryItemById(libraryItemId) {
|
|
var libraryItem = this.db.libraryItems.find(li => li.id === libraryItemId)
|
|
if (!libraryItem) {
|
|
Logger.error(`[Scanner] Scan libraryItem by id not found ${libraryItemId}`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
const library = this.db.libraries.find(lib => lib.id === libraryItem.libraryId)
|
|
if (!library) {
|
|
Logger.error(`[Scanner] Scan libraryItem by id library not found "${libraryItem.libraryId}"`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
const folder = library.folders.find(f => f.id === libraryItem.folderId)
|
|
if (!folder) {
|
|
Logger.error(`[Scanner] Scan libraryItem by id folder not found "${libraryItem.folderId}" in library "${library.name}"`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
Logger.info(`[Scanner] Scanning Library Item "${libraryItem.media.metadata.title}"`)
|
|
return this.scanLibraryItem(library.mediaType, folder, libraryItem)
|
|
}
|
|
|
|
async scanLibraryItem(libraryMediaType, folder, libraryItem) {
|
|
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, this.db.serverSettings)
|
|
if (!libraryItemData) {
|
|
return ScanResult.NOTHING
|
|
}
|
|
var hasUpdated = false
|
|
|
|
var checkRes = libraryItem.checkScanData(libraryItemData)
|
|
if (checkRes.updated) hasUpdated = true
|
|
|
|
// Sync other files first so that local images are used as cover art
|
|
if (await libraryItem.syncFiles(this.db.serverSettings.scannerPreferOpfMetadata)) {
|
|
hasUpdated = true
|
|
}
|
|
|
|
// Scan all audio files
|
|
if (libraryItem.hasAudioFiles) {
|
|
var libraryAudioFiles = libraryItem.libraryFiles.filter(lf => lf.fileType === 'audio')
|
|
if (await AudioFileScanner.scanAudioFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata)) {
|
|
hasUpdated = true
|
|
}
|
|
|
|
// Extract embedded cover art if cover is not already in directory
|
|
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
|
var coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
|
if (coverPath) {
|
|
Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`)
|
|
hasUpdated = true
|
|
}
|
|
}
|
|
}
|
|
|
|
await this.createNewAuthorsAndSeries(libraryItem)
|
|
|
|
if (!libraryItem.hasMediaEntities) { // Library Item is invalid
|
|
libraryItem.setInvalid()
|
|
hasUpdated = true
|
|
} else if (libraryItem.isInvalid) {
|
|
libraryItem.isInvalid = false
|
|
hasUpdated = true
|
|
}
|
|
|
|
if (hasUpdated) {
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
return ScanResult.UPDATED
|
|
}
|
|
return ScanResult.UPTODATE
|
|
}
|
|
|
|
async scan(library, options = {}) {
|
|
if (this.isLibraryScanning(library.id)) {
|
|
Logger.error(`[Scanner] Already scanning ${library.id}`)
|
|
return
|
|
}
|
|
|
|
if (!library.folders.length) {
|
|
Logger.warn(`[Scanner] Library has no folders to scan "${library.name}"`)
|
|
return
|
|
}
|
|
|
|
var scanOptions = new ScanOptions()
|
|
scanOptions.setData(options, this.db.serverSettings)
|
|
|
|
var libraryScan = new LibraryScan()
|
|
libraryScan.setData(library, scanOptions)
|
|
libraryScan.verbose = false
|
|
this.librariesScanning.push(libraryScan.getScanEmitData)
|
|
|
|
this.emitter('scan_start', libraryScan.getScanEmitData)
|
|
|
|
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
|
|
|
var canceled = await this.scanLibrary(libraryScan)
|
|
|
|
if (canceled) {
|
|
Logger.info(`[Scanner] Library scan canceled for "${libraryScan.libraryName}"`)
|
|
delete this.cancelLibraryScan[libraryScan.libraryId]
|
|
}
|
|
|
|
libraryScan.setComplete()
|
|
|
|
Logger.info(`[Scanner] Library scan ${libraryScan.id} completed in ${libraryScan.elapsedTimestamp} | ${libraryScan.resultStats}`)
|
|
this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
|
|
|
if (canceled && !libraryScan.totalResults) {
|
|
var emitData = libraryScan.getScanEmitData
|
|
emitData.results = null
|
|
this.emitter('scan_complete', emitData)
|
|
return
|
|
}
|
|
|
|
this.emitter('scan_complete', libraryScan.getScanEmitData)
|
|
|
|
if (libraryScan.totalResults) {
|
|
libraryScan.saveLog(this.ScanLogPath)
|
|
}
|
|
}
|
|
|
|
async scanLibrary(libraryScan) {
|
|
var libraryItemDataFound = []
|
|
|
|
// Scan each library
|
|
for (let i = 0; i < libraryScan.folders.length; i++) {
|
|
var folder = libraryScan.folders[i]
|
|
var itemDataFoundInFolder = await scanFolder(libraryScan.libraryMediaType, folder, this.db.serverSettings)
|
|
libraryScan.addLog(LogLevel.INFO, `${itemDataFoundInFolder.length} item data found in folder "${folder.fullPath}"`)
|
|
libraryItemDataFound = libraryItemDataFound.concat(itemDataFoundInFolder)
|
|
}
|
|
|
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
|
|
|
// Remove items with no inode
|
|
libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino)
|
|
var libraryItemsInLibrary = this.db.libraryItems.filter(li => li.libraryId === libraryScan.libraryId)
|
|
|
|
const MaxSizePerChunk = 2.5e9
|
|
const itemDataToRescanChunks = []
|
|
const newItemDataToScanChunks = []
|
|
var itemsToUpdate = []
|
|
var itemDataToRescan = []
|
|
var itemDataToRescanSize = 0
|
|
var newItemDataToScan = []
|
|
var newItemDataToScanSize = 0
|
|
var itemsToFindCovers = []
|
|
|
|
// Check for existing & removed library items
|
|
for (let i = 0; i < libraryItemsInLibrary.length; i++) {
|
|
var libraryItem = libraryItemsInLibrary[i]
|
|
// Find library item folder with matching inode or matching path
|
|
var dataFound = libraryItemDataFound.find(lid => lid.ino === libraryItem.ino || comparePaths(lid.relPath, libraryItem.relPath))
|
|
if (!dataFound) {
|
|
libraryScan.addLog(LogLevel.WARN, `Library Item "${libraryItem.media.metadata.title}" is missing`)
|
|
libraryScan.resultsMissing++
|
|
libraryItem.setMissing()
|
|
itemsToUpdate.push(libraryItem)
|
|
} else {
|
|
var checkRes = libraryItem.checkScanData(dataFound)
|
|
if (checkRes.newLibraryFiles.length || libraryScan.scanOptions.forceRescan) { // Item has new files
|
|
checkRes.libraryItem = libraryItem
|
|
checkRes.scanData = dataFound
|
|
|
|
// If this item will go over max size then push current chunk
|
|
if (libraryItem.audioFileTotalSize + itemDataToRescanSize > MaxSizePerChunk && itemDataToRescan.length > 0) {
|
|
itemDataToRescanChunks.push(itemDataToRescan)
|
|
itemDataToRescanSize = 0
|
|
itemDataToRescan = []
|
|
}
|
|
|
|
itemDataToRescan.push(checkRes)
|
|
itemDataToRescanSize += libraryItem.audioFileTotalSize
|
|
if (itemDataToRescanSize >= MaxSizePerChunk) {
|
|
itemDataToRescanChunks.push(itemDataToRescan)
|
|
itemDataToRescanSize = 0
|
|
itemDataToRescan = []
|
|
}
|
|
} else if (libraryScan.findCovers && libraryItem.media.shouldSearchForCover) { // Search cover
|
|
libraryScan.resultsUpdated++
|
|
itemsToFindCovers.push(libraryItem)
|
|
itemsToUpdate.push(libraryItem)
|
|
} else if (checkRes.updated) { // Updated but no scan required
|
|
libraryScan.resultsUpdated++
|
|
itemsToUpdate.push(libraryItem)
|
|
}
|
|
libraryItemDataFound = libraryItemDataFound.filter(lid => lid.ino !== dataFound.ino)
|
|
}
|
|
}
|
|
if (itemDataToRescan.length) itemDataToRescanChunks.push(itemDataToRescan)
|
|
|
|
// Potential NEW Library Items
|
|
for (let i = 0; i < libraryItemDataFound.length; i++) {
|
|
var dataFound = libraryItemDataFound[i]
|
|
|
|
var hasMediaFile = dataFound.libraryFiles.some(lf => lf.isMediaFile)
|
|
if (!hasMediaFile) {
|
|
libraryScan.addLog(LogLevel.WARN, `Directory found "${libraryItemDataFound.path}" has no media files`)
|
|
} else {
|
|
var audioFileSize = 0
|
|
dataFound.libraryFiles.filter(lf => lf.fileType == 'audio').forEach(lf => audioFileSize += lf.metadata.size)
|
|
|
|
// If this item will go over max size then push current chunk
|
|
if (audioFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
|
|
newItemDataToScanChunks.push(newItemDataToScan)
|
|
newItemDataToScanSize = 0
|
|
newItemDataToScan = []
|
|
}
|
|
|
|
newItemDataToScan.push(dataFound)
|
|
newItemDataToScanSize += audioFileSize
|
|
if (newItemDataToScanSize >= MaxSizePerChunk) {
|
|
newItemDataToScanChunks.push(newItemDataToScan)
|
|
newItemDataToScanSize = 0
|
|
newItemDataToScan = []
|
|
}
|
|
}
|
|
}
|
|
if (newItemDataToScan.length) newItemDataToScanChunks.push(newItemDataToScan)
|
|
|
|
// Library Items not requiring a scan but require a search for cover
|
|
for (let i = 0; i < itemsToFindCovers.length; i++) {
|
|
var libraryItem = itemsToFindCovers[i]
|
|
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
|
libraryItem.media.updateLastCoverSearch(updatedCover)
|
|
}
|
|
|
|
if (itemsToUpdate.length) {
|
|
await this.updateLibraryItemChunk(itemsToUpdate)
|
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
|
}
|
|
for (let i = 0; i < itemDataToRescanChunks.length; i++) {
|
|
await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan)
|
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
|
// console.log('Rescan chunk done', i, 'of', itemDataToRescanChunks.length)
|
|
}
|
|
for (let i = 0; i < newItemDataToScanChunks.length; i++) {
|
|
await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan)
|
|
// console.log('New scan chunk done', i, 'of', newItemDataToScanChunks.length)
|
|
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
|
|
}
|
|
}
|
|
|
|
async updateLibraryItemChunk(itemsToUpdate) {
|
|
await this.db.updateLibraryItems(itemsToUpdate)
|
|
this.emitter('items_updated', itemsToUpdate.map(li => li.toJSONExpanded()))
|
|
}
|
|
|
|
async rescanLibraryItemDataChunk(itemDataToRescan, libraryScan) {
|
|
var itemsUpdated = await Promise.all(itemDataToRescan.map((lid) => {
|
|
return this.rescanLibraryItem(lid, libraryScan)
|
|
}))
|
|
|
|
for (const libraryItem of itemsUpdated) {
|
|
// Temp authors & series are inserted - create them if found
|
|
await this.createNewAuthorsAndSeries(libraryItem)
|
|
}
|
|
|
|
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
|
|
if (itemsUpdated.length) {
|
|
libraryScan.resultsUpdated += itemsUpdated.length
|
|
await this.db.updateLibraryItems(itemsUpdated)
|
|
this.emitter('items_updated', itemsUpdated.map(li => li.toJSONExpanded()))
|
|
}
|
|
}
|
|
|
|
async scanNewLibraryItemDataChunk(newLibraryItemsData, libraryScan) {
|
|
var newLibraryItems = await Promise.all(newLibraryItemsData.map((lid) => {
|
|
return this.scanNewLibraryItem(lid, libraryScan.libraryMediaType, libraryScan.preferAudioMetadata, libraryScan.preferOpfMetadata, libraryScan.findCovers, libraryScan)
|
|
}))
|
|
newLibraryItems = newLibraryItems.filter(li => li) // Filter out nulls
|
|
|
|
for (const libraryItem of newLibraryItems) {
|
|
// Temp authors & series are inserted - create them if found
|
|
await this.createNewAuthorsAndSeries(libraryItem)
|
|
}
|
|
|
|
libraryScan.resultsAdded += newLibraryItems.length
|
|
await this.db.insertLibraryItems(newLibraryItems)
|
|
this.emitter('items_added', newLibraryItems.map(li => li.toJSONExpanded()))
|
|
}
|
|
|
|
async rescanLibraryItem(libraryItemCheckData, libraryScan) {
|
|
const { newLibraryFiles, filesRemoved, existingLibraryFiles, libraryItem, scanData, updated } = libraryItemCheckData
|
|
libraryScan.addLog(LogLevel.DEBUG, `Library "${libraryScan.libraryName}" Re-scanning "${libraryItem.path}"`)
|
|
var hasUpdated = updated
|
|
|
|
// Sync other files first to use local images as cover before extracting audio file cover
|
|
if (await libraryItem.syncFiles(libraryScan.preferOpfMetadata)) {
|
|
hasUpdated = true
|
|
}
|
|
|
|
// forceRescan all existing audio files - will probe and update ID3 tag metadata
|
|
var existingAudioFiles = existingLibraryFiles.filter(lf => lf.fileType === 'audio')
|
|
if (libraryScan.scanOptions.forceRescan && existingAudioFiles.length) {
|
|
if (await AudioFileScanner.scanAudioFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
|
hasUpdated = true
|
|
}
|
|
}
|
|
// Scan new audio files
|
|
var newAudioFiles = newLibraryFiles.filter(lf => lf.fileType === 'audio')
|
|
var removedAudioFiles = filesRemoved.filter(lf => lf.fileType === 'audio')
|
|
if (newAudioFiles.length || removedAudioFiles.length) {
|
|
if (await AudioFileScanner.scanAudioFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan)) {
|
|
hasUpdated = true
|
|
}
|
|
}
|
|
// If an audio file has embedded cover art and no cover is set yet, extract & use it
|
|
if (newAudioFiles.length || libraryScan.scanOptions.forceRescan) {
|
|
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
|
var savedCoverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
|
if (savedCoverPath) {
|
|
hasUpdated = true
|
|
libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${savedCoverPath}"`)
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!libraryItem.hasMediaEntities) { // Library item is invalid
|
|
libraryItem.setInvalid()
|
|
hasUpdated = true
|
|
} else if (libraryItem.isInvalid) {
|
|
libraryItem.isInvalid = false
|
|
hasUpdated = true
|
|
}
|
|
|
|
// Scan for cover if enabled and has no cover (and author or title has changed OR has been 7 days since last lookup)
|
|
if (libraryScan.findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
|
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
|
libraryItem.media.updateLastCoverSearch(updatedCover)
|
|
hasUpdated = true
|
|
}
|
|
|
|
return hasUpdated ? libraryItem : null
|
|
}
|
|
|
|
async scanNewLibraryItem(libraryItemData, libraryMediaType, preferAudioMetadata, preferOpfMetadata, findCovers, libraryScan = null) {
|
|
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Scanning new library item "${libraryItemData.path}"`)
|
|
else Logger.debug(`[Scanner] Scanning new item "${libraryItemData.path}"`)
|
|
|
|
var libraryItem = new LibraryItem()
|
|
libraryItem.setData(libraryMediaType, libraryItemData)
|
|
|
|
var audioFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio')
|
|
if (audioFiles.length) {
|
|
await AudioFileScanner.scanAudioFiles(audioFiles, libraryItemData, libraryItem, preferAudioMetadata, libraryScan)
|
|
}
|
|
|
|
if (!libraryItem.hasMediaEntities) {
|
|
Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`)
|
|
return null
|
|
}
|
|
|
|
await libraryItem.syncFiles(preferOpfMetadata)
|
|
|
|
// Extract embedded cover art if cover is not already in directory
|
|
if (libraryItem.media.hasEmbeddedCoverArt && !libraryItem.media.coverPath) {
|
|
var coverPath = await this.coverManager.saveEmbeddedCoverArt(libraryItem)
|
|
if (coverPath) {
|
|
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Saved embedded cover art "${coverPath}"`)
|
|
else Logger.debug(`[Scanner] Saved embedded cover art "${coverPath}"`)
|
|
}
|
|
}
|
|
|
|
// Scan for cover if enabled and has no cover
|
|
if (libraryMediaType !== 'podcast') {
|
|
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
|
|
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
|
|
libraryItem.media.updateLastCoverSearch(updatedCover)
|
|
}
|
|
}
|
|
|
|
return libraryItem
|
|
}
|
|
|
|
async createNewAuthorsAndSeries(libraryItem) {
|
|
if (libraryItem.mediaType !== 'book') return
|
|
|
|
// Create or match all new authors and series
|
|
if (libraryItem.media.metadata.authors.some(au => au.id.startsWith('new'))) {
|
|
var newAuthors = []
|
|
libraryItem.media.metadata.authors = libraryItem.media.metadata.authors.map((tempMinAuthor) => {
|
|
var _author = this.db.authors.find(au => au.checkNameEquals(tempMinAuthor.name))
|
|
if (!_author) _author = newAuthors.find(au => au.checkNameEquals(tempMinAuthor.name)) // Check new unsaved authors
|
|
if (!_author) {
|
|
_author = new Author()
|
|
_author.setData(tempMinAuthor)
|
|
newAuthors.push(_author)
|
|
}
|
|
return {
|
|
id: _author.id,
|
|
name: _author.name
|
|
}
|
|
})
|
|
if (newAuthors.length) {
|
|
await this.db.insertEntities('author', newAuthors)
|
|
this.emitter('authors_added', newAuthors.map(au => au.toJSON()))
|
|
}
|
|
}
|
|
if (libraryItem.media.metadata.series.some(se => se.id.startsWith('new'))) {
|
|
var newSeries = []
|
|
libraryItem.media.metadata.series = libraryItem.media.metadata.series.map((tempMinSeries) => {
|
|
var _series = this.db.series.find(se => se.checkNameEquals(tempMinSeries.name))
|
|
if (!_series) _series = newSeries.find(se => se.checkNameEquals(tempMinSeries.name)) // Check new unsaved series
|
|
if (!_series) {
|
|
_series = new Series()
|
|
_series.setData(tempMinSeries)
|
|
newSeries.push(_series)
|
|
}
|
|
return {
|
|
id: _series.id,
|
|
name: _series.name,
|
|
sequence: tempMinSeries.sequence
|
|
}
|
|
})
|
|
if (newSeries.length) {
|
|
await this.db.insertEntities('series', newSeries)
|
|
this.emitter('series_added', newSeries.map(se => se.toJSON()))
|
|
}
|
|
}
|
|
}
|
|
|
|
getFileUpdatesGrouped(fileUpdates) {
|
|
var folderGroups = {}
|
|
fileUpdates.forEach((file) => {
|
|
if (folderGroups[file.folderId]) {
|
|
folderGroups[file.folderId].fileUpdates.push(file)
|
|
} else {
|
|
folderGroups[file.folderId] = {
|
|
libraryId: file.libraryId,
|
|
folderId: file.folderId,
|
|
fileUpdates: [file]
|
|
}
|
|
}
|
|
})
|
|
return folderGroups
|
|
}
|
|
|
|
async scanFilesChanged(fileUpdates) {
|
|
if (!fileUpdates.length) return
|
|
// files grouped by folder
|
|
var folderGroups = this.getFileUpdatesGrouped(fileUpdates)
|
|
|
|
for (const folderId in folderGroups) {
|
|
var libraryId = folderGroups[folderId].libraryId
|
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
|
if (!library) {
|
|
Logger.error(`[Scanner] Library not found in files changed ${libraryId}`)
|
|
continue;
|
|
}
|
|
var folder = library.getFolderById(folderId)
|
|
if (!folder) {
|
|
Logger.error(`[Scanner] Folder is not in library in files changed "${folderId}", Library "${library.name}"`)
|
|
continue;
|
|
}
|
|
var relFilePaths = folderGroups[folderId].fileUpdates.map(fileUpdate => fileUpdate.relPath)
|
|
var fileUpdateGroup = groupFilesIntoLibraryItemPaths(relFilePaths, true)
|
|
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
|
|
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
|
|
}
|
|
}
|
|
|
|
async scanFolderUpdates(library, folder, fileUpdateGroup) {
|
|
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
|
Logger.debug(`[Scanner] scanFolderUpdates fileUpdateGroup`, fileUpdateGroup)
|
|
|
|
// First pass - Remove files in parent dirs of items and remap the fileupdate group
|
|
// Test Case: Moving audio files from library item folder to author folder should trigger a re-scan of the item
|
|
var updateGroup = { ...fileUpdateGroup }
|
|
for (const itemDir in updateGroup) {
|
|
var itemDirNestedFiles = fileUpdateGroup[itemDir].filter(b => b.includes('/'))
|
|
if (!itemDirNestedFiles.length) continue;
|
|
|
|
var firstNest = itemDirNestedFiles[0].split('/').shift()
|
|
var altDir = `${itemDir}/${firstNest}`
|
|
|
|
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
|
var childLibraryItem = this.db.libraryItems.find(li => li.path !== fullPath && li.fullPath.startsWith(fullPath))
|
|
if (!childLibraryItem) {
|
|
continue;
|
|
}
|
|
var altFullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), altDir)
|
|
var altChildLibraryItem = this.db.libraryItems.find(li => li.path !== altFullPath && li.path.startsWith(altFullPath))
|
|
if (altChildLibraryItem) {
|
|
continue;
|
|
}
|
|
|
|
delete fileUpdateGroup[itemDir]
|
|
fileUpdateGroup[altDir] = itemDirNestedFiles.map((f) => f.split('/').slice(1).join('/'))
|
|
Logger.warn(`[Scanner] Some files were modified in a parent directory of a library item "${childLibraryItem.title}" - ignoring`)
|
|
}
|
|
|
|
// Second pass: Check for new/updated/removed items
|
|
var itemGroupingResults = {}
|
|
for (const itemDir in fileUpdateGroup) {
|
|
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), itemDir)
|
|
|
|
// Check if book dir group is already an item
|
|
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
|
|
if (existingLibraryItem) {
|
|
// Is the item exactly - check if was deleted
|
|
if (existingLibraryItem.path === fullPath) {
|
|
var exists = await fs.pathExists(fullPath)
|
|
if (!exists) {
|
|
Logger.info(`[Scanner] Scanning file update group and library item was deleted "${existingLibraryItem.media.metadata.title}" - marking as missing`)
|
|
existingLibraryItem.setMissing()
|
|
await this.db.updateLibraryItem(existingLibraryItem)
|
|
this.emitter('item_updated', existingLibraryItem.toJSONExpanded())
|
|
|
|
itemGroupingResults[itemDir] = ScanResult.REMOVED
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Scan library item for updates
|
|
Logger.debug(`[Scanner] Folder update for relative path "${itemDir}" is in library item "${existingLibraryItem.media.metadata.title}" - scan for updates`)
|
|
itemGroupingResults[itemDir] = await this.scanLibraryItem(library.mediaType, folder, existingLibraryItem)
|
|
continue;
|
|
}
|
|
|
|
// Check if a library item is a subdirectory of this dir
|
|
var childItem = this.db.libraryItems.find(li => li.path.startsWith(fullPath))
|
|
if (childItem) {
|
|
Logger.warn(`[Scanner] Files were modified in a parent directory of a library item "${childItem.media.metadata.title}" - ignoring`)
|
|
itemGroupingResults[itemDir] = ScanResult.NOTHING
|
|
continue;
|
|
}
|
|
|
|
Logger.debug(`[Scanner] Folder update group must be a new item "${itemDir}" in library "${library.name}"`)
|
|
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath)
|
|
if (newLibraryItem) {
|
|
await this.createNewAuthorsAndSeries(newLibraryItem)
|
|
await this.db.insertLibraryItem(newLibraryItem)
|
|
this.emitter('item_added', newLibraryItem.toJSONExpanded())
|
|
}
|
|
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
|
|
}
|
|
|
|
return itemGroupingResults
|
|
}
|
|
|
|
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath) {
|
|
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, this.db.serverSettings)
|
|
if (!libraryItemData) return null
|
|
var serverSettings = this.db.serverSettings
|
|
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers)
|
|
}
|
|
|
|
async searchForCover(libraryItem, libraryScan = null) {
|
|
var options = {
|
|
titleDistance: 2,
|
|
authorDistance: 2
|
|
}
|
|
var scannerCoverProvider = this.db.serverSettings.scannerCoverProvider
|
|
var results = await this.bookFinder.findCovers(scannerCoverProvider, libraryItem.media.metadata.title, libraryItem.media.metadata.authorName, options)
|
|
if (results.length) {
|
|
if (libraryScan) libraryScan.addLog(LogLevel.DEBUG, `Found best cover for "${libraryItem.media.metadata.title}"`)
|
|
else Logger.debug(`[Scanner] Found best cover for "${libraryItem.media.metadata.title}"`)
|
|
|
|
// If the first cover result fails, attempt to download the second
|
|
for (let i = 0; i < results.length && i < 2; i++) {
|
|
|
|
// Downloads and updates the book cover
|
|
var result = await this.coverManager.downloadCoverFromUrl(libraryItem, results[i])
|
|
|
|
if (result.error) {
|
|
Logger.error(`[Scanner] Failed to download cover from url "${results[i]}" | Attempt ${i + 1}`, result.error)
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TODO: Redo metadata
|
|
async saveMetadata(audiobookId) {
|
|
// if (audiobookId) {
|
|
// var audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
// if (!audiobook) {
|
|
// return {
|
|
// error: 'Audiobook not found'
|
|
// }
|
|
// }
|
|
// var savedPath = await audiobook.writeNfoFile()
|
|
// return {
|
|
// audiobookId,
|
|
// audiobookTitle: audiobook.title,
|
|
// savedPath
|
|
// }
|
|
// } else {
|
|
// var response = {
|
|
// success: 0,
|
|
// failed: 0
|
|
// }
|
|
// for (let i = 0; i < this.db.audiobooks.length; i++) {
|
|
// var audiobook = this.db.audiobooks[i]
|
|
// var savedPath = await audiobook.writeNfoFile()
|
|
// if (savedPath) {
|
|
// Logger.info(`[Scanner] Saved metadata nfo ${savedPath}`)
|
|
// response.success++
|
|
// } else {
|
|
// response.failed++
|
|
// }
|
|
// }
|
|
// return response
|
|
// }
|
|
}
|
|
|
|
async quickMatchBook(libraryItem, options = {}) {
|
|
var provider = options.provider || 'google'
|
|
var searchTitle = options.title || libraryItem.media.metadata.title
|
|
var searchAuthor = options.author || libraryItem.media.metadata.authorName
|
|
|
|
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor)
|
|
if (!results.length) {
|
|
return {
|
|
warning: `No ${provider} match found`
|
|
}
|
|
}
|
|
var matchData = results[0]
|
|
|
|
// Update cover if not set OR overrideCover flag
|
|
var hasUpdated = false
|
|
if (matchData.cover && (!libraryItem.media.coverPath || options.overrideCover)) {
|
|
Logger.debug(`[Scanner] Updating cover "${matchData.cover}"`)
|
|
var coverResult = await this.coverManager.downloadCoverFromUrl(libraryItem, matchData.cover)
|
|
if (!coverResult || coverResult.error || !coverResult.cover) {
|
|
Logger.warn(`[Scanner] Match cover "${matchData.cover}" failed to use: ${coverResult ? coverResult.error : 'Unknown Error'}`)
|
|
} else {
|
|
hasUpdated = true
|
|
}
|
|
}
|
|
|
|
// Update media metadata if not set OR overrideDetails flag
|
|
const detailKeysToUpdate = ['title', 'subtitle', 'narrator', 'publisher', 'publishedYear', 'asin', 'isbn']
|
|
const updatePayload = {}
|
|
for (const key in matchData) {
|
|
if (matchData[key] && detailKeysToUpdate.includes(key)) {
|
|
if (key === 'narrator') {
|
|
if ((!libraryItem.media.metadata.narratorName || options.overrideDetails)) {
|
|
updatePayload.narrators = [matchData[key]]
|
|
}
|
|
} else if ((!libraryItem.media.metadata[key] || options.overrideDetails)) {
|
|
updatePayload[key] = matchData[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add or set author if not set
|
|
if (matchData.author && !libraryItem.media.metadata.authorName) {
|
|
var author = this.db.authors.find(au => au.checkNameEquals(matchData.author))
|
|
if (!author) {
|
|
author = new Author()
|
|
author.setData({ name: matchData.author })
|
|
await this.db.insertEntity('author', author)
|
|
this.emitter('author_added', author)
|
|
}
|
|
updatePayload.authors = [author.toJSONMinimal()]
|
|
}
|
|
|
|
// Add or set series if not set
|
|
if (matchData.series && !libraryItem.media.metadata.seriesName) {
|
|
var seriesItem = this.db.series.find(au => au.checkNameEquals(matchData.series))
|
|
if (!seriesItem) {
|
|
seriesItem = new Series()
|
|
seriesItem.setData({ name: matchData.series })
|
|
await this.db.insertEntity('series', seriesItem)
|
|
this.emitter('series_added', seriesItem)
|
|
}
|
|
updatePayload.series = [seriesItem.toJSONMinimal(matchData.volumeNumber)]
|
|
}
|
|
|
|
if (Object.keys(updatePayload).length) {
|
|
Logger.debug('[Scanner] Updating details', updatePayload)
|
|
if (libraryItem.media.update({ metadata: updatePayload })) {
|
|
hasUpdated = true
|
|
}
|
|
}
|
|
|
|
if (hasUpdated) {
|
|
await this.db.updateLibraryItem(libraryItem)
|
|
this.emitter('item_updated', libraryItem.toJSONExpanded())
|
|
}
|
|
|
|
return {
|
|
updated: hasUpdated,
|
|
libraryItem: libraryItem.toJSONExpanded()
|
|
}
|
|
}
|
|
|
|
// TODO: Redo quick match full library
|
|
async matchLibraryBooks(library) {
|
|
// if (this.isLibraryScanning(library.id)) {
|
|
// Logger.error(`[Scanner] Already scanning ${library.id}`)
|
|
// return
|
|
// }
|
|
|
|
// const provider = library.provider || 'google'
|
|
// var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === library.id)
|
|
// if (!audiobooksInLibrary.length) {
|
|
// return
|
|
// }
|
|
|
|
// var libraryScan = new LibraryScan()
|
|
// libraryScan.setData(library, null, 'match')
|
|
// this.librariesScanning.push(libraryScan.getScanEmitData)
|
|
// this.emitter('scan_start', libraryScan.getScanEmitData)
|
|
|
|
// Logger.info(`[Scanner] Starting library match books scan ${libraryScan.id} for ${libraryScan.libraryName}`)
|
|
|
|
// for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
|
// var audiobook = audiobooksInLibrary[i]
|
|
// Logger.debug(`[Scanner] Quick matching "${audiobook.title}" (${i + 1} of ${audiobooksInLibrary.length})`)
|
|
// var result = await this.quickMatchBook(audiobook, { provider })
|
|
// if (result.warning) {
|
|
// Logger.warn(`[Scanner] Match warning ${result.warning} for audiobook "${audiobook.title}"`)
|
|
// } else if (result.updated) {
|
|
// libraryScan.resultsUpdated++
|
|
// }
|
|
|
|
// if (this.cancelLibraryScan[libraryScan.libraryId]) {
|
|
// Logger.info(`[Scanner] Library match scan canceled for "${libraryScan.libraryName}"`)
|
|
// delete this.cancelLibraryScan[libraryScan.libraryId]
|
|
// var scanData = libraryScan.getScanEmitData
|
|
// scanData.results = false
|
|
// this.emitter('scan_complete', scanData)
|
|
// this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
|
// return
|
|
// }
|
|
// }
|
|
|
|
// this.librariesScanning = this.librariesScanning.filter(ls => ls.id !== library.id)
|
|
// this.emitter('scan_complete', libraryScan.getScanEmitData)
|
|
}
|
|
}
|
|
module.exports = Scanner |