audiobookshelf/server/scanner/Scanner.js
2021-11-24 20:15:50 -06:00

237 lines
9.6 KiB
JavaScript

const fs = require('fs-extra')
const Path = require('path')
// Utils
const Logger = require('../Logger')
const { version } = require('../../package.json')
const audioFileScanner = require('../utils/audioFileScanner')
const { groupFilesIntoAudiobookPaths, getAudiobookFileData, scanRootDir } = require('../utils/scandir')
const { comparePaths, getIno, getId, msToTimestamp } = require('../utils/index')
const { ScanResult, CoverDestination } = require('../utils/constants')
const AudioFileScanner = require('./AudioFileScanner')
const BookFinder = require('../BookFinder')
const Audiobook = require('../objects/Audiobook')
const LibraryScan = require('./LibraryScan')
const ScanOptions = require('./ScanOptions')
class Scanner {
constructor(AUDIOBOOK_PATH, METADATA_PATH, db, coverController, emitter) {
this.AudiobookPath = AUDIOBOOK_PATH
this.MetadataPath = METADATA_PATH
this.BookMetadataPath = Path.posix.join(this.MetadataPath.replace(/\\/g, '/'), 'books')
this.db = db
this.coverController = coverController
this.emitter = emitter
this.cancelScan = false
this.cancelLibraryScan = {}
this.librariesScanning = []
this.bookFinder = new BookFinder()
}
getCoverDirectory(audiobook) {
if (this.db.serverSettings.coverDestination === CoverDestination.AUDIOBOOK) {
return {
fullPath: audiobook.fullPath,
relPath: '/s/book/' + audiobook.id
}
} else {
return {
fullPath: Path.posix.join(this.BookMetadataPath, audiobook.id),
relPath: Path.posix.join('/metadata', 'books', audiobook.id)
}
}
}
async scan(libraryId, options = {}) {
if (this.librariesScanning.includes(libraryId)) {
Logger.error(`[Scanner] Already scanning ${libraryId}`)
return
}
var library = this.db.libraries.find(lib => lib.id === libraryId)
if (!library) {
Logger.error(`[Scanner] Library not found for scan ${libraryId}`)
return
} else 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)
this.librariesScanning.push(libraryScan)
this.emitter('scan_start', libraryScan.getScanEmitData)
Logger.info(`[Scanner] Starting library scan ${libraryScan.id} for ${libraryScan.libraryName}`)
await this.scanLibrary(libraryScan)
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)
this.emitter('scan_complete', libraryScan.getScanEmitData)
}
async scanLibrary(libraryScan) {
var audiobookDataFound = []
for (let i = 0; i < libraryScan.folders.length; i++) {
var folder = libraryScan.folders[i]
var abDataFoundInFolder = await scanRootDir(folder, this.db.serverSettings)
Logger.debug(`[Scanner] ${abDataFoundInFolder.length} ab data found in folder "${folder.fullPath}"`)
audiobookDataFound = audiobookDataFound.concat(abDataFoundInFolder)
}
// Remove audiobooks with no inode
audiobookDataFound = audiobookDataFound.filter(abd => abd.ino)
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryScan.libraryId)
var audiobooksToUpdate = []
var audiobookRescans = []
var newAudiobookScans = []
// Check for existing & removed audiobooks
for (let i = 0; i < audiobooksInLibrary.length; i++) {
var audiobook = audiobooksInLibrary[i]
var dataFound = audiobookDataFound.find(abd => abd.ino === audiobook.ino || comparePaths(abd.path, audiobook.path))
if (!dataFound) {
Logger.info(`[Scanner] Audiobook "${audiobook.title}" is missing`)
audiobook.setMissing()
audiobooksToUpdate.push(audiobook)
} else {
var checkRes = audiobook.checkScanData(dataFound)
if (checkRes.newAudioFileData.length || checkRes.newOtherFileData.length) {
// existing audiobook has new files
checkRes.audiobook = audiobook
checkRes.bookScanData = dataFound
audiobookRescans.push(this.rescanAudiobook(checkRes, libraryScan))
libraryScan.resultsMissing++
} else if (checkRes.updated) {
audiobooksToUpdate.push(audiobook)
libraryScan.resultsUpdated++
}
audiobookDataFound = audiobookDataFound.filter(abf => abf.ino !== dataFound.ino)
}
}
// Potential NEW Audiobooks
for (let i = 0; i < audiobookDataFound.length; i++) {
var dataFound = audiobookDataFound[i]
var hasEbook = dataFound.otherFiles.find(otherFile => otherFile.filetype === 'ebook')
if (!hasEbook && !dataFound.audioFiles.length) {
Logger.info(`[Scanner] Directory found "${audiobookDataFound.path}" has no ebook or audio files`)
} else {
newAudiobookScans.push(this.scanNewAudiobook(dataFound, libraryScan))
}
}
if (audiobookRescans.length) {
var updatedAudiobooks = (await Promise.all(audiobookRescans)).filter(ab => !!ab)
if (updatedAudiobooks.length) {
audiobooksToUpdate = audiobooksToUpdate.concat(updatedAudiobooks)
libraryScan.resultsUpdated += updatedAudiobooks.length
}
}
if (audiobooksToUpdate.length) {
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" updating ${audiobooksToUpdate.length} books`)
await this.db.updateEntities('audiobook', audiobooksToUpdate)
}
if (newAudiobookScans.length) {
var newAudiobooks = (await Promise.all(newAudiobookScans)).filter(ab => !!ab)
if (newAudiobooks.length) {
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" inserting ${newAudiobooks.length} books`)
await this.db.insertEntities('audiobook', newAudiobooks)
libraryScan.resultsAdded = newAudiobooks.length
}
}
}
async rescanAudiobook(audiobookCheckData, libraryScan) {
const { newAudioFileData, newOtherFileData, audiobook, bookScanData } = audiobookCheckData
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" Re-scanning "${audiobook.path}"`)
if (newAudioFileData.length) {
var audioScanResult = await AudioFileScanner.scanAudioFiles(newAudioFileData, bookScanData)
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" Book "${audiobook.path}" Audio file scan took ${msToTimestamp(audioScanResult.elapsed, true)} for ${audioScanResult.audioFiles.length} with average time of ${msToTimestamp(audioScanResult.averageScanDuration, true)}`)
if (audioScanResult.audioFiles.length) {
var totalAudioFilesToInclude = audiobook.audioFilesToInclude.length + audioScanResult.audioFiles.length
// validate & add audio files to audiobook
for (let i = 0; i < audioScanResult.audioFiles.length; i++) {
var newAF = audioScanResult.audioFiles[i]
var trackIndex = newAF.validateTrackIndex(totalAudioFilesToInclude === 1)
if (trackIndex !== null) {
if (audiobook.checkHasTrackNum(trackIndex)) {
newAF.setDuplicateTrackNumber(trackIndex)
} else {
newAF.index = trackIndex
}
}
audiobook.addAudioFile(newAF)
}
audiobook.rebuildTracks()
}
}
if (newOtherFileData.length) {
await audiobook.syncOtherFiles(newOtherFileData, this.MetadataPath)
}
return audiobook
}
async scanNewAudiobook(audiobookData, libraryScan) {
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" Scanning new "${audiobookData.path}"`)
var audiobook = new Audiobook()
audiobook.setData(audiobookData)
if (audiobookData.audioFiles.length) {
var audioScanResult = await AudioFileScanner.scanAudioFiles(audiobookData.audioFiles, audiobookData)
Logger.debug(`[Scanner] Library "${libraryScan.libraryName}" Book "${audiobookData.path}" Audio file scan took ${msToTimestamp(audioScanResult.elapsed, true)} for ${audioScanResult.audioFiles.length} with average time of ${msToTimestamp(audioScanResult.averageScanDuration, true)}`)
if (audioScanResult.audioFiles.length) {
// validate & add audio files to audiobook
for (let i = 0; i < audioScanResult.audioFiles.length; i++) {
var newAF = audioScanResult.audioFiles[i]
var trackIndex = newAF.validateTrackIndex(audioScanResult.audioFiles.length === 1)
if (trackIndex !== null) {
if (audiobook.checkHasTrackNum(trackIndex)) {
newAF.setDuplicateTrackNumber(trackIndex)
} else {
newAF.index = trackIndex
}
}
audiobook.addAudioFile(newAF)
}
audiobook.rebuildTracks()
} else if (!audiobook.ebooks.length) {
// Audiobook has no ebooks and no valid audio tracks do not continue
Logger.warn(`[Scanner] Audiobook has no ebooks and no valid audio tracks "${audiobook.path}"`)
return null
}
}
// Look for desc.txt and reader.txt and update
await audiobook.saveDataFromTextFiles()
// Extract embedded cover art if cover is not already in directory
if (audiobook.hasEmbeddedCoverArt && !audiobook.cover) {
var outputCoverDirs = this.getCoverDirectory(audiobook)
var relativeDir = await audiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
if (relativeDir) {
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
}
}
return audiobook
}
}
module.exports = Scanner