audiobookshelf/server/scanner/Scanner.js
Lars Kiesow 90299e348c
Fix Sub-path Detection
If the scanner detects new files with a path containing part of the name
of an already existing library item, the new item will incorrectly be
detected as being a parent directory of the already existing item and
the import will be aborted.

You can follow these steps to reproduce the issue:

```
❯ mkdir audiobooks/author/

❯ mv title\ 10 audiobooks/author
[2022-12-18 22:14:12] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 10/dictaphone.mp3
[2022-12-18 22:14:16] DEBUG: [DB] Library Items inserted 1

❯ mv title\ 1 audiobooks/author
[2022-12-18 22:15:03] DEBUG: [Watcher] File Added /home/lars/dev/audiobookshelf/audiobooks/author/title 1/dictaphone.mp3
[2022-12-18 22:15:07]  WARN: [Scanner] Files were modified in a parent directory of a library item "title 10" - ignoring
```

Since `'title 10'.startsWith('title 1')` is `true`, the current code
makes this false assumption.

This patch fixes the issue by requiring a path separator to be part of
the matching path. This should ensure that only true parent directories
are detected.

This patch requires audiobookshelf to always use Unix file separators.
But that shouldn't be a problem since audiobookshelf always seems to use
these kinds of separators. Even on Windows.
2022-12-18 22:23:50 +01:00

1002 lines
42 KiB
JavaScript

const fs = require('../libs/fsExtra')
const Path = require('path')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
// Utils
const { groupFilesIntoLibraryItemPaths, getLibraryItemFileData, scanFolder } = require('../utils/scandir')
const { comparePaths } = require('../utils/index')
const { getIno } = require('../utils/fileUtils')
const { ScanResult, LogLevel } = require('../utils/constants')
const { findMatchingEpisodesInFeed, getPodcastFeed } = require('../utils/podcastUtils')
const MediaFileScanner = require('./MediaFileScanner')
const BookFinder = require('../finders/BookFinder')
const PodcastFinder = require('../finders/PodcastFinder')
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) {
this.db = db
this.coverManager = coverManager
this.cancelLibraryScan = {}
this.librariesScanning = []
// Watcher file update scan vars
this.pendingFileUpdatesToScan = []
this.scanningFilesChanged = false
this.bookFinder = new BookFinder()
this.podcastFinder = new PodcastFinder()
}
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) {
// TODO: Support for single media item
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, libraryItem.path, false, 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 MediaFileScanner.scanMediaFiles(libraryAudioFiles, libraryItemData, libraryItem, this.db.serverSettings.scannerPreferAudioMetadata, this.db.serverSettings.scannerPreferOverdriveMediaMarker)) {
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)
// Library Item is invalid - (a book has no audio files or ebook files)
if (!libraryItem.hasMediaEntities && libraryItem.mediaType !== 'podcast') {
libraryItem.setInvalid()
hasUpdated = true
} else if (libraryItem.isInvalid) {
libraryItem.isInvalid = false
hasUpdated = true
}
if (hasUpdated) {
SocketAuthority.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)
SocketAuthority.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
SocketAuthority.emitter('scan_complete', emitData)
return
}
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
if (libraryScan.totalResults) {
libraryScan.saveLog()
}
}
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 (global.ServerSettings.scannerUseSingleThreadedProber) {
// 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 {
itemDataToRescan.push(checkRes)
}
} 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, `Item found "${libraryItemDataFound.path}" has no media files`)
} else {
if (global.ServerSettings.scannerUseSingleThreadedProber) {
// If this item will go over max size then push current chunk
var mediaFileSize = 0
dataFound.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video').forEach(lf => mediaFileSize += lf.metadata.size)
if (mediaFileSize + newItemDataToScanSize > MaxSizePerChunk && newItemDataToScan.length > 0) {
newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0
newItemDataToScan = []
}
newItemDataToScan.push(dataFound)
newItemDataToScanSize += mediaFileSize
if (newItemDataToScanSize >= MaxSizePerChunk) {
newItemDataToScanChunks.push(newItemDataToScan)
newItemDataToScanSize = 0
newItemDataToScan = []
}
} else { // Chunking is not necessary for new scanner
newItemDataToScan.push(dataFound)
}
}
}
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
}
// Chunking will be removed when legacy single threaded scanner is removed
for (let i = 0; i < itemDataToRescanChunks.length; i++) {
await this.rescanLibraryItemDataChunk(itemDataToRescanChunks[i], libraryScan)
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
}
for (let i = 0; i < newItemDataToScanChunks.length; i++) {
await this.scanNewLibraryItemDataChunk(newItemDataToScanChunks[i], libraryScan)
if (this.cancelLibraryScan[libraryScan.libraryId]) return true
}
}
async updateLibraryItemChunk(itemsToUpdate) {
await this.db.updateLibraryItems(itemsToUpdate)
SocketAuthority.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)
}))
itemsUpdated = itemsUpdated.filter(li => li) // Filter out nulls
for (const libraryItem of itemsUpdated) {
// Temp authors & series are inserted - create them if found
await this.createNewAuthorsAndSeries(libraryItem)
}
if (itemsUpdated.length) {
libraryScan.resultsUpdated += itemsUpdated.length
await this.db.updateLibraryItems(itemsUpdated)
SocketAuthority.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.preferOverdriveMediaMarker, 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)
SocketAuthority.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 MediaFileScanner.scanMediaFiles(existingAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, 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 MediaFileScanner.scanMediaFiles(newAudioFiles, scanData, libraryItem, libraryScan.preferAudioMetadata, libraryScan.preferOverdriveMediaMarker, 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}"`)
}
}
}
// Library Item is invalid - (a book has no audio files or ebook files)
if (!libraryItem.hasMediaEntities && libraryItem.mediaType !== 'podcast') {
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, preferOverdriveMediaMarker, 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 mediaFiles = libraryItemData.libraryFiles.filter(lf => lf.fileType === 'audio' || lf.fileType === 'video')
if (mediaFiles.length) {
await MediaFileScanner.scanMediaFiles(mediaFiles, libraryItemData, libraryItem, preferAudioMetadata, preferOverdriveMediaMarker, libraryScan)
}
await libraryItem.syncFiles(preferOpfMetadata)
if (!libraryItem.hasMediaEntities) {
Logger.warn(`[Scanner] Library item has no media files "${libraryItemData.path}"`)
return null
}
// 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 === 'book') {
if (libraryItem && findCovers && !libraryItem.media.coverPath && libraryItem.media.shouldSearchForCover) {
var updatedCover = await this.searchForCover(libraryItem, libraryScan)
libraryItem.media.updateLastCoverSearch(updatedCover)
}
}
return libraryItem
}
// Any series or author object on library item with an id starting with "new"
// will create a new author/series OR find a matching author/series
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) { // Must create new 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)
SocketAuthority.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) { // Must create new 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)
SocketAuthority.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 || !fileUpdates.length) return
// If already scanning files from watcher then add these updates to queue
if (this.scanningFilesChanged) {
this.pendingFileUpdatesToScan.push(fileUpdates)
Logger.debug(`[Scanner] Already scanning files from watcher - file updates pushed to queue (size ${this.pendingFileUpdatesToScan.length})`)
return
}
this.scanningFilesChanged = true
// 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(library.mediaType, relFilePaths)
if (!Object.keys(fileUpdateGroup).length) {
Logger.info(`[Scanner] No important changes to scan for in folder "${folderId}"`)
continue;
}
var folderScanResults = await this.scanFolderUpdates(library, folder, fileUpdateGroup)
Logger.debug(`[Scanner] Folder scan results`, folderScanResults)
}
this.scanningFilesChanged = false
if (this.pendingFileUpdatesToScan.length) {
Logger.debug(`[Scanner] File updates finished scanning with more updates in queue (${this.pendingFileUpdatesToScan.length})`)
this.scanFilesChanged(this.pendingFileUpdatesToScan.shift())
}
}
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) {
if (itemDir == fileUpdateGroup[itemDir]) continue; // Media in root path
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.path.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)
const dirIno = await getIno(fullPath)
// Check if book dir group is already an item
var existingLibraryItem = this.db.libraryItems.find(li => fullPath.startsWith(li.path))
if (!existingLibraryItem) {
existingLibraryItem = this.db.libraryItems.find(li => li.ino === dirIno)
if (existingLibraryItem) {
Logger.debug(`[Scanner] scanFolderUpdates: Library item found by inode value "${existingLibraryItem.relPath} => ${itemDir}"`)
// Update library item paths for scan and all library item paths will get updated in LibraryItem.checkScanData
existingLibraryItem.path = fullPath
existingLibraryItem.relPath = itemDir
}
}
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)
SocketAuthority.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 isSingleMediaItem = itemDir === fileUpdateGroup[itemDir]
var newLibraryItem = await this.scanPotentialNewLibraryItem(library.mediaType, folder, fullPath, isSingleMediaItem)
if (newLibraryItem) {
await this.createNewAuthorsAndSeries(newLibraryItem)
await this.db.insertLibraryItem(newLibraryItem)
SocketAuthority.emitter('item_added', newLibraryItem.toJSONExpanded())
}
itemGroupingResults[itemDir] = newLibraryItem ? ScanResult.ADDED : ScanResult.NOTHING
}
return itemGroupingResults
}
async scanPotentialNewLibraryItem(libraryMediaType, folder, fullPath, isSingleMediaItem = false) {
var libraryItemData = await getLibraryItemFileData(libraryMediaType, folder, fullPath, isSingleMediaItem, this.db.serverSettings)
if (!libraryItemData) return null
var serverSettings = this.db.serverSettings
return this.scanNewLibraryItem(libraryItemData, libraryMediaType, serverSettings.scannerPreferAudioMetadata, serverSettings.scannerPreferOpfMetadata, serverSettings.scannerFindCovers, serverSettings.scannerPreferOverdriveMediaMarker)
}
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
}
async quickMatchLibraryItem(libraryItem, options = {}) {
var provider = options.provider || 'google'
var searchTitle = options.title || libraryItem.media.metadata.title
var searchAuthor = options.author || libraryItem.media.metadata.authorName
var overrideDefaults = options.overrideDefaults || false
// Set to override existing metadata if scannerPreferMatchedMetadata setting is true and
// the overrideDefaults option is not set or set to false.
if ((overrideDefaults == false) && (this.db.serverSettings.scannerPreferMatchedMetadata)) {
options.overrideCover = true
options.overrideDetails = true
}
var updatePayload = {}
var hasUpdated = false
if (libraryItem.isBook) {
var searchISBN = options.isbn || libraryItem.media.metadata.isbn
var searchASIN = options.asin || libraryItem.media.metadata.asin
var results = await this.bookFinder.search(provider, searchTitle, searchAuthor, searchISBN, searchASIN)
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
// Update cover if not set OR overrideCover flag
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
}
}
updatePayload = await this.quickMatchBookBuildUpdatePayload(libraryItem, matchData, options)
} else if (libraryItem.isPodcast) { // Podcast quick match
var results = await this.podcastFinder.search(searchTitle)
if (!results.length) {
return {
warning: `No ${provider} match found`
}
}
var matchData = results[0]
// Update cover if not set OR overrideCover flag
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
}
}
updatePayload = this.quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options)
}
if (Object.keys(updatePayload).length) {
Logger.debug('[Scanner] Updating details', updatePayload)
if (libraryItem.media.update(updatePayload)) {
hasUpdated = true
}
}
if (hasUpdated) {
if (libraryItem.isPodcast && libraryItem.media.metadata.feedUrl) { // Quick match all unmatched podcast episodes
await this.quickMatchPodcastEpisodes(libraryItem, options)
}
await this.db.updateLibraryItem(libraryItem)
SocketAuthority.emitter('item_updated', libraryItem.toJSONExpanded())
}
return {
updated: hasUpdated,
libraryItem: libraryItem.toJSONExpanded()
}
}
quickMatchPodcastBuildUpdatePayload(libraryItem, matchData, options) {
const updatePayload = {}
updatePayload.metadata = {}
const matchDataTransformed = {
title: matchData.title || null,
author: matchData.artistName || null,
genres: matchData.genres || [],
itunesId: matchData.id || null,
itunesPageUrl: matchData.pageUrl || null,
itunesArtistId: matchData.artistId || null,
releaseDate: matchData.releaseDate || null,
imageUrl: matchData.cover || null,
feedUrl: matchData.feedUrl || null,
description: matchData.descriptionPlain || null
}
for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) {
if (key === 'genres') {
if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) {
var genresArray = []
if (Array.isArray(matchDataTransformed[key])) genresArray = [...matchDataTransformed[key]]
else { // Genres should always be passed in as an array but just incase handle a string
Logger.warn(`[Scanner] quickMatch genres is not an array ${matchDataTransformed[key]}`)
genresArray = matchDataTransformed[key].split(',').map(v => v.trim()).filter(v => !!v)
}
updatePayload.metadata[key] = genresArray
}
} else if (libraryItem.media.metadata[key] !== matchDataTransformed[key] && (!libraryItem.media.metadata[key] || options.overrideDetails)) {
updatePayload.metadata[key] = matchDataTransformed[key]
}
}
}
if (!Object.keys(updatePayload.metadata).length) {
delete updatePayload.metadata
}
return updatePayload
}
async quickMatchBookBuildUpdatePayload(libraryItem, matchData, options) {
// Update media metadata if not set OR overrideDetails flag
const detailKeysToUpdate = ['title', 'subtitle', 'description', 'narrator', 'publisher', 'publishedYear', 'genres', 'tags', 'language', 'explicit', 'asin', 'isbn']
const updatePayload = {}
updatePayload.metadata = {}
for (const key in matchData) {
if (matchData[key] && detailKeysToUpdate.includes(key)) {
if (key === 'narrator') {
if ((!libraryItem.media.metadata.narratorName || options.overrideDetails)) {
updatePayload.metadata.narrators = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
}
} else if (key === 'genres') {
if ((!libraryItem.media.metadata.genres.length || options.overrideDetails)) {
var genresArray = []
if (Array.isArray(matchData[key])) genresArray = [...matchData[key]]
else { // Genres should always be passed in as an array but just incase handle a string
Logger.warn(`[Scanner] quickMatch genres is not an array ${matchData[key]}`)
genresArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
}
updatePayload.metadata[key] = genresArray
}
} else if (key === 'tags') {
if ((!libraryItem.media.tags.length || options.overrideDetails)) {
var tagsArray = []
if (Array.isArray(matchData[key])) tagsArray = [...matchData[key]]
else tagsArray = matchData[key].split(',').map(v => v.trim()).filter(v => !!v)
updatePayload[key] = tagsArray
}
} else if ((!libraryItem.media.metadata[key] || options.overrideDetails)) {
updatePayload.metadata[key] = matchData[key]
}
}
}
// Add or set author if not set
if (matchData.author && (!libraryItem.media.metadata.authorName || options.overrideDetails)) {
if (!Array.isArray(matchData.author)) {
matchData.author = matchData.author.split(',').map(au => au.trim()).filter(au => !!au)
}
const authorPayload = []
for (let index = 0; index < matchData.author.length; index++) {
const authorName = matchData.author[index]
var author = this.db.authors.find(au => au.checkNameEquals(authorName))
if (!author) {
author = new Author()
author.setData({ name: authorName })
await this.db.insertEntity('author', author)
SocketAuthority.emitter('author_added', author)
}
authorPayload.push(author.toJSONMinimal())
}
updatePayload.metadata.authors = authorPayload
}
// Add or set series if not set
if (matchData.series && (!libraryItem.media.metadata.seriesName || options.overrideDetails)) {
if (!Array.isArray(matchData.series)) matchData.series = [{ series: matchData.series, sequence: matchData.sequence }]
const seriesPayload = []
for (let index = 0; index < matchData.series.length; index++) {
const seriesMatchItem = matchData.series[index]
var seriesItem = this.db.series.find(au => au.checkNameEquals(seriesMatchItem.series))
if (!seriesItem) {
seriesItem = new Series()
seriesItem.setData({ name: seriesMatchItem.series })
await this.db.insertEntity('series', seriesItem)
SocketAuthority.emitter('series_added', seriesItem)
}
seriesPayload.push(seriesItem.toJSONMinimal(seriesMatchItem.sequence))
}
updatePayload.metadata.series = seriesPayload
}
if (!Object.keys(updatePayload.metadata).length) {
delete updatePayload.metadata
}
return updatePayload
}
async quickMatchPodcastEpisodes(libraryItem, options = {}) {
const episodesToQuickMatch = libraryItem.media.episodes.filter(ep => !ep.enclosureUrl) // Only quick match episodes without enclosure
if (!episodesToQuickMatch.length) return false
const feed = await getPodcastFeed(libraryItem.media.metadata.feedUrl)
if (!feed) {
Logger.error(`[Scanner] quickMatchPodcastEpisodes: Unable to quick match episodes feed not found for "${libraryItem.media.metadata.feedUrl}"`)
return false
}
var episodesWereUpdated = false
for (const episode of episodesToQuickMatch) {
const episodeMatches = findMatchingEpisodesInFeed(feed, episode.title)
if (episodeMatches && episodeMatches.length) {
const wasUpdated = this.updateEpisodeWithMatch(libraryItem, episode, episodeMatches[0].episode, options)
if (wasUpdated) episodesWereUpdated = true
}
}
return episodesWereUpdated
}
updateEpisodeWithMatch(libraryItem, episode, episodeToMatch, options = {}) {
Logger.debug(`[Scanner] quickMatchPodcastEpisodes: Found episode match for "${episode.title}" => ${episodeToMatch.title}`)
const matchDataTransformed = {
title: episodeToMatch.title || '',
subtitle: episodeToMatch.subtitle || '',
description: episodeToMatch.description || '',
enclosure: episodeToMatch.enclosure || null,
episode: episodeToMatch.episode || '',
episodeType: episodeToMatch.episodeType || '',
season: episodeToMatch.season || '',
pubDate: episodeToMatch.pubDate || '',
publishedAt: episodeToMatch.publishedAt
}
const updatePayload = {}
for (const key in matchDataTransformed) {
if (matchDataTransformed[key]) {
if (key === 'enclosure') {
if (!episode.enclosure || JSON.stringify(episode.enclosure) !== JSON.stringify(matchDataTransformed.enclosure)) {
updatePayload[key] = {
...matchDataTransformed.enclosure
}
}
} else if (episode[key] !== matchDataTransformed[key] && (!episode[key] || options.overrideDetails)) {
updatePayload[key] = matchDataTransformed[key]
}
}
}
if (Object.keys(updatePayload).length) {
return libraryItem.media.updateEpisode(episode.id, updatePayload)
}
return false
}
async matchLibraryItems(library) {
if (library.mediaType === 'podcast') {
Logger.error(`[Scanner] matchLibraryItems: Match all not supported for podcasts yet`)
return
}
if (this.isLibraryScanning(library.id)) {
Logger.error(`[Scanner] matchLibraryItems: Already scanning ${library.id}`)
return
}
var itemsInLibrary = this.db.getLibraryItemsInLibrary(library.id)
if (!itemsInLibrary.length) {
Logger.error(`[Scanner] matchLibraryItems: Library has no items ${library.id}`)
return
}
const provider = library.provider
var libraryScan = new LibraryScan()
libraryScan.setData(library, null, 'match')
this.librariesScanning.push(libraryScan.getScanEmitData)
SocketAuthority.emitter('scan_start', libraryScan.getScanEmitData)
Logger.info(`[Scanner] matchLibraryItems: Starting library match scan ${libraryScan.id} for ${libraryScan.libraryName}`)
for (let i = 0; i < itemsInLibrary.length; i++) {
var libraryItem = itemsInLibrary[i]
if (libraryItem.media.metadata.asin && library.settings.skipMatchingMediaWithAsin) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
}" because it already has an ASIN (${i + 1} of ${itemsInLibrary.length})`)
continue;
}
if (libraryItem.media.metadata.isbn && library.settings.skipMatchingMediaWithIsbn) {
Logger.debug(`[Scanner] matchLibraryItems: Skipping "${libraryItem.media.metadata.title
}" because it already has an ISBN (${i + 1} of ${itemsInLibrary.length})`)
continue;
}
Logger.debug(`[Scanner] matchLibraryItems: Quick matching "${libraryItem.media.metadata.title}" (${i + 1} of ${itemsInLibrary.length})`)
var result = await this.quickMatchLibraryItem(libraryItem, { provider })
if (result.warning) {
Logger.warn(`[Scanner] matchLibraryItems: Match warning ${result.warning} for library item "${libraryItem.media.metadata.title}"`)
} else if (result.updated) {
libraryScan.resultsUpdated++
}
if (this.cancelLibraryScan[libraryScan.libraryId]) {
Logger.info(`[Scanner] matchLibraryItems: Library match scan canceled for "${libraryScan.libraryName}"`)
delete this.cancelLibraryScan[libraryScan.libraryId]
var scanData = libraryScan.getScanEmitData
scanData.results = false
SocketAuthority.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)
SocketAuthority.emitter('scan_complete', libraryScan.getScanEmitData)
}
probeAudioFileWithTone(audioFile) {
return MediaFileScanner.probeAudioFileWithTone(audioFile)
}
}
module.exports = Scanner