mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2024-11-08 17:14:18 +01:00
768 lines
30 KiB
JavaScript
768 lines
30 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, secondsToTimestamp } = require('./utils/index')
|
|
const { ScanResult, CoverDestination } = require('./utils/constants')
|
|
|
|
const BookFinder = require('./BookFinder')
|
|
const Audiobook = require('./objects/Audiobook')
|
|
|
|
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()
|
|
}
|
|
|
|
get audiobooks() {
|
|
return this.db.audiobooks
|
|
}
|
|
|
|
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 setAudioFileInos(audiobookDataAudioFiles, audiobookAudioFiles) {
|
|
for (let i = 0; i < audiobookDataAudioFiles.length; i++) {
|
|
var abdFile = audiobookDataAudioFiles[i]
|
|
var matchingFile = audiobookAudioFiles.find(af => comparePaths(af.path, abdFile.path))
|
|
if (matchingFile) {
|
|
if (!matchingFile.ino) {
|
|
matchingFile.ino = await getIno(matchingFile.fullPath)
|
|
}
|
|
abdFile.ino = matchingFile.ino
|
|
} else {
|
|
abdFile.ino = await getIno(abdFile.fullPath)
|
|
if (!abdFile.ino) {
|
|
Logger.error('[Scanner] Invalid abdFile ino - ignoring abd audio file', abdFile.path)
|
|
}
|
|
}
|
|
}
|
|
return audiobookDataAudioFiles.filter(abdFile => !!abdFile.ino)
|
|
}
|
|
|
|
// Only updates audio files with matching paths
|
|
syncAudiobookInodeValues(audiobook, { audioFiles, otherFiles }) {
|
|
var filesUpdated = 0
|
|
|
|
// Sync audio files & audio tracks with updated inodes
|
|
audiobook._audioFiles.forEach((audioFile) => {
|
|
var matchingAudioFile = audioFiles.find(af => af.ino !== audioFile.ino && af.path === audioFile.path)
|
|
if (matchingAudioFile) {
|
|
// Audio Track should always have the same ino as the equivalent audio file (not all audio files have a track)
|
|
var audioTrack = audiobook.tracks.find(t => t.ino === audioFile.ino)
|
|
if (audioTrack) {
|
|
Logger.debug(`[Scanner] Found audio file & track with mismatched inode "${audioFile.filename}" - updating it`)
|
|
audioTrack.ino = matchingAudioFile.ino
|
|
filesUpdated++
|
|
} else {
|
|
Logger.debug(`[Scanner] Found audio file with mismatched inode "${audioFile.filename}" - updating it`)
|
|
}
|
|
|
|
audioFile.ino = matchingAudioFile.ino
|
|
filesUpdated++
|
|
}
|
|
})
|
|
|
|
// Sync other files with updated inodes
|
|
audiobook._otherFiles.forEach((otherFile) => {
|
|
var matchingOtherFile = otherFiles.find(of => of.ino !== otherFile.ino && of.path === otherFile.path)
|
|
if (matchingOtherFile) {
|
|
Logger.debug(`[Scanner] Found other file with mismatched inode "${otherFile.filename}" - updating it`)
|
|
otherFile.ino = matchingOtherFile.ino
|
|
filesUpdated++
|
|
}
|
|
})
|
|
|
|
return filesUpdated
|
|
}
|
|
|
|
async searchForCover(audiobook) {
|
|
var options = {
|
|
titleDistance: 2,
|
|
authorDistance: 2
|
|
}
|
|
var results = await this.bookFinder.findCovers('openlibrary', audiobook.title, audiobook.authorFL, options)
|
|
if (results.length) {
|
|
Logger.debug(`[Scanner] Found best cover for "${audiobook.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.coverController.downloadCoverFromUrl(audiobook, 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 scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, hasUpdatedLibraryOrFolder, forceAudioFileScan) {
|
|
// Always sync files and inode values
|
|
var filesInodeUpdated = this.syncAudiobookInodeValues(existingAudiobook, audiobookData)
|
|
if (hasUpdatedIno || filesInodeUpdated > 0) {
|
|
Logger.info(`[Scanner] Updating inode value for "${existingAudiobook.title}" - ${filesInodeUpdated} files updated`)
|
|
hasUpdatedIno = true
|
|
}
|
|
|
|
// TEMP: Check if is older audiobook and needs force rescan
|
|
if (!forceAudioFileScan && (!existingAudiobook.scanVersion || existingAudiobook.checkHasOldCoverPath())) {
|
|
Logger.info(`[Scanner] Force rescan for "${existingAudiobook.title}" | Last scan v${existingAudiobook.scanVersion} | Old Cover Path ${!!existingAudiobook.checkHasOldCoverPath()}`)
|
|
forceAudioFileScan = true
|
|
}
|
|
|
|
// inode is required
|
|
audiobookData.audioFiles = audiobookData.audioFiles.filter(af => af.ino)
|
|
|
|
// No valid ebook and audio files found, mark as incomplete
|
|
var ebookFiles = audiobookData.otherFiles.filter(f => f.filetype === 'ebook')
|
|
if (!audiobookData.audioFiles.length && !ebookFiles.length) {
|
|
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid book files found - marking as incomplete`)
|
|
existingAudiobook.setLastScan(version)
|
|
existingAudiobook.isInvalid = true
|
|
await this.db.updateAudiobook(existingAudiobook)
|
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
|
return ScanResult.UPDATED
|
|
} else if (existingAudiobook.isInvalid) { // Was incomplete but now is not
|
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was incomplete but now has book files`)
|
|
existingAudiobook.isInvalid = false
|
|
}
|
|
|
|
// Check for audio files that were removed
|
|
var abdAudioFileInos = audiobookData.audioFiles.map(af => af.ino)
|
|
var removedAudioFiles = existingAudiobook.audioFiles.filter(file => !abdAudioFileInos.includes(file.ino))
|
|
if (removedAudioFiles.length) {
|
|
Logger.info(`[Scanner] ${removedAudioFiles.length} audio files removed for audiobook "${existingAudiobook.title}"`)
|
|
removedAudioFiles.forEach((af) => existingAudiobook.removeAudioFile(af))
|
|
}
|
|
|
|
// Check for mismatched audio tracks - tracks with no matching audio file
|
|
var removedAudioTracks = existingAudiobook.tracks.filter(track => !abdAudioFileInos.includes(track.ino))
|
|
if (removedAudioTracks.length) {
|
|
Logger.error(`[Scanner] ${removedAudioTracks.length} tracks removed no matching audio file for audiobook "${existingAudiobook.title}"`)
|
|
removedAudioTracks.forEach((at) => existingAudiobook.removeAudioTrack(at))
|
|
}
|
|
|
|
// Check for new audio files and sync existing audio files
|
|
var newAudioFiles = []
|
|
var hasUpdatedAudioFiles = false
|
|
audiobookData.audioFiles.forEach((file) => {
|
|
var existingAudioFile = existingAudiobook.getAudioFileByIno(file.ino)
|
|
if (existingAudioFile) { // Audio file exists, sync path (path may have been renamed)
|
|
if (existingAudiobook.syncAudioFile(existingAudioFile, file)) {
|
|
hasUpdatedAudioFiles = true
|
|
}
|
|
} else {
|
|
// New audio file, triple check for matching file path
|
|
var audioFileWithMatchingPath = existingAudiobook.getAudioFileByPath(file.fullPath)
|
|
if (audioFileWithMatchingPath) {
|
|
Logger.warn(`[Scanner] Audio file with path already exists with different inode, New: "${file.filename}" (${file.ino}) | Existing: ${audioFileWithMatchingPath.filename} (${audioFileWithMatchingPath.ino})`)
|
|
} else {
|
|
newAudioFiles.push(file)
|
|
}
|
|
}
|
|
})
|
|
|
|
|
|
// Sync other files (all files that are not audio files) - Updates cover path
|
|
var hasOtherFileUpdates = false
|
|
var otherFilesUpdated = await existingAudiobook.syncOtherFiles(audiobookData.otherFiles, this.MetadataPath, false, forceAudioFileScan)
|
|
if (otherFilesUpdated) {
|
|
hasOtherFileUpdates = true
|
|
}
|
|
|
|
// Rescan audio file metadata
|
|
if (forceAudioFileScan) {
|
|
Logger.info(`[Scanner] Rescanning ${existingAudiobook.audioFiles.length} audio files for "${existingAudiobook.title}"`)
|
|
var numAudioFilesUpdated = await audioFileScanner.rescanAudioFiles(existingAudiobook)
|
|
|
|
// Set book details from metadata pulled from audio files
|
|
var bookMetadataUpdated = existingAudiobook.setDetailsFromFileMetadata()
|
|
if (bookMetadataUpdated) {
|
|
Logger.debug(`[Scanner] Book Metadata Updated for "${existingAudiobook.title}"`)
|
|
hasUpdatedAudioFiles = true
|
|
}
|
|
|
|
if (numAudioFilesUpdated > 0) {
|
|
Logger.info(`[Scanner] Rescan complete, ${numAudioFilesUpdated} audio files were updated for "${existingAudiobook.title}"`)
|
|
hasUpdatedAudioFiles = true
|
|
|
|
// Use embedded cover art if audiobook has no cover
|
|
if (existingAudiobook.hasEmbeddedCoverArt && !existingAudiobook.cover) {
|
|
var outputCoverDirs = this.getCoverDirectory(existingAudiobook)
|
|
var relativeDir = await existingAudiobook.saveEmbeddedCoverArt(outputCoverDirs.fullPath, outputCoverDirs.relPath)
|
|
if (relativeDir) {
|
|
Logger.debug(`[Scanner] Saved embedded cover art "${relativeDir}"`)
|
|
}
|
|
}
|
|
} else {
|
|
Logger.info(`[Scanner] Rescan complete, audio files were up to date for "${existingAudiobook.title}"`)
|
|
}
|
|
}
|
|
|
|
// Scan and add new audio files found and set tracks
|
|
if (newAudioFiles.length) {
|
|
Logger.info(`[Scanner] ${newAudioFiles.length} new audio files were found for audiobook "${existingAudiobook.title}"`)
|
|
await audioFileScanner.scanAudioFiles(existingAudiobook, newAudioFiles)
|
|
}
|
|
|
|
// After scanning audio files, some may no longer be valid
|
|
// so make sure the directory still has valid book files
|
|
if (!existingAudiobook.tracks.length && !ebookFiles.length) {
|
|
Logger.error(`[Scanner] "${existingAudiobook.title}" no valid book files found after update - marking as incomplete`)
|
|
existingAudiobook.setLastScan(version)
|
|
existingAudiobook.isInvalid = true
|
|
await this.db.updateAudiobook(existingAudiobook)
|
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
|
return ScanResult.UPDATED
|
|
}
|
|
|
|
var hasUpdates = hasOtherFileUpdates || hasUpdatedIno || hasUpdatedLibraryOrFolder || removedAudioFiles.length || removedAudioTracks.length || newAudioFiles.length || hasUpdatedAudioFiles
|
|
|
|
// Check that audio tracks are in sequential order with no gaps
|
|
if (existingAudiobook.checkUpdateMissingTracks()) {
|
|
Logger.info(`[Scanner] "${existingAudiobook.title}" missing parts updated`)
|
|
hasUpdates = true
|
|
}
|
|
|
|
// Syncs path and fullPath
|
|
if (existingAudiobook.syncPaths(audiobookData)) {
|
|
hasUpdates = true
|
|
}
|
|
|
|
// If audiobook was missing before, it is now found
|
|
if (existingAudiobook.isMissing) {
|
|
existingAudiobook.isMissing = false
|
|
hasUpdates = true
|
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was missing but now it is found`)
|
|
}
|
|
|
|
if (hasUpdates || version !== existingAudiobook.scanVersion) {
|
|
existingAudiobook.setChapters()
|
|
existingAudiobook.setLastScan(version)
|
|
await this.db.updateAudiobook(existingAudiobook)
|
|
|
|
Logger.info(`[Scanner] "${existingAudiobook.title}" was updated`)
|
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
|
return ScanResult.UPDATED
|
|
}
|
|
|
|
return ScanResult.UPTODATE
|
|
}
|
|
|
|
async scanNewAudiobook(audiobookData) {
|
|
var ebookFiles = audiobookData.otherFiles.map(f => f.filetype === 'ebook')
|
|
if (!audiobookData.audioFiles.length && !ebookFiles.length) {
|
|
Logger.error('[Scanner] No valid audio files and ebooks for Audiobook', audiobookData.path)
|
|
return null
|
|
}
|
|
|
|
var audiobook = new Audiobook()
|
|
audiobook.setData(audiobookData)
|
|
|
|
// Scan audio files and set tracks, pulls metadata
|
|
await audioFileScanner.scanAudioFiles(audiobook, audiobookData.audioFiles)
|
|
|
|
if (!audiobook.tracks.length && !audiobook.ebooks.length) {
|
|
Logger.warn('[Scanner] Invalid audiobook, no valid audio tracks and ebook files', audiobook.title)
|
|
return null
|
|
}
|
|
|
|
// Look for desc.txt and reader.txt and update
|
|
await audiobook.saveDataFromTextFiles(false)
|
|
|
|
// 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}"`)
|
|
}
|
|
}
|
|
|
|
// Set book details from metadata pulled from audio files
|
|
audiobook.setDetailsFromFileMetadata()
|
|
|
|
// Check for gaps in track numbers
|
|
audiobook.checkUpdateMissingTracks()
|
|
|
|
// Set chapters from audio files
|
|
audiobook.setChapters()
|
|
|
|
audiobook.setLastScan(version)
|
|
|
|
Logger.info(`[Scanner] Audiobook "${audiobook.title}" Scanned (${audiobook.sizePretty}) [${audiobook.durationPretty}]`)
|
|
await this.db.insertEntity('audiobook', audiobook)
|
|
this.emitter('audiobook_added', audiobook.toJSONMinified())
|
|
return audiobook
|
|
}
|
|
|
|
async scanAudiobookData(audiobookData, forceAudioFileScan = false) {
|
|
var scannerFindCovers = this.db.serverSettings.scannerFindCovers
|
|
var libraryId = audiobookData.libraryId
|
|
var folderId = audiobookData.folderId
|
|
|
|
var hasUpdatedLibraryOrFolder = false
|
|
|
|
var existingAudiobook = this.audiobooks.find(ab => ab.ino === audiobookData.ino)
|
|
|
|
// Make sure existing audiobook has the same library & folder id
|
|
if (existingAudiobook && (existingAudiobook.libraryId !== libraryId || existingAudiobook.folderId !== folderId)) {
|
|
var existingAudiobookLibrary = this.db.libraries.find(lib => lib.id === existingAudiobook.libraryId)
|
|
|
|
if (!existingAudiobookLibrary) {
|
|
Logger.error(`[Scanner] Audiobook "${existingAudiobook.title}" found in different library that no longer exists ${existingAudiobook.libraryId}`)
|
|
} else if (existingAudiobook.libraryId !== libraryId) {
|
|
Logger.warn(`[Scanner] Audiobook "${existingAudiobook.title}" found in different library "${existingAudiobookLibrary.name}"`)
|
|
} else {
|
|
Logger.warn(`[Scanner] Audiobook "${existingAudiobook.title}" found in different folder "${existingAudiobook.folderId}" of library "${existingAudiobookLibrary.name}"`)
|
|
}
|
|
|
|
existingAudiobook.libraryId = libraryId
|
|
existingAudiobook.folderId = folderId
|
|
hasUpdatedLibraryOrFolder = true
|
|
Logger.info(`[Scanner] Updated Audiobook "${existingAudiobook.title}" library and folder to "${libraryId}" "${folderId}"`)
|
|
}
|
|
|
|
// inode value may change when using shared drives, update inode if matching path is found
|
|
// Note: inode will not change on rename
|
|
var hasUpdatedIno = false
|
|
if (!existingAudiobook) {
|
|
// check an audiobook exists with matching path, then update inodes
|
|
existingAudiobook = this.audiobooks.find(a => a.path === audiobookData.path)
|
|
if (existingAudiobook) {
|
|
var oldIno = existingAudiobook.ino
|
|
existingAudiobook.ino = audiobookData.ino
|
|
Logger.debug(`[Scanner] Scan Audiobook Data: Updated inode from "${oldIno}" to "${existingAudiobook.ino}"`)
|
|
hasUpdatedIno = true
|
|
|
|
if (existingAudiobook.libraryId !== libraryId || existingAudiobook.folderId !== folderId) {
|
|
Logger.warn(`[Scanner] Audiobook found by path is in a different library or folder, ${existingAudiobook.libraryId}/${existingAudiobook.folderId} should be ${libraryId}/${folderId}`)
|
|
|
|
existingAudiobook.libraryId = libraryId
|
|
existingAudiobook.folderId = folderId
|
|
hasUpdatedLibraryOrFolder = true
|
|
Logger.info(`[Scanner] Updated Audiobook "${existingAudiobook.title}" library and folder to "${libraryId}" "${folderId}"`)
|
|
}
|
|
}
|
|
}
|
|
|
|
var scanResult = null
|
|
var finalAudiobook = null
|
|
|
|
if (existingAudiobook) {
|
|
finalAudiobook = existingAudiobook
|
|
|
|
scanResult = await this.scanExistingAudiobook(existingAudiobook, audiobookData, hasUpdatedIno, hasUpdatedLibraryOrFolder, forceAudioFileScan)
|
|
|
|
if (scanResult === ScanResult.REMOVED || scanResult === ScanResult.NOTHING) {
|
|
finalAudiobook = null
|
|
}
|
|
} else {
|
|
finalAudiobook = await this.scanNewAudiobook(audiobookData)
|
|
|
|
scanResult = finalAudiobook ? ScanResult.ADDED : ScanResult.NOTHING
|
|
|
|
if (finalAudiobook === ScanResult.NOTHING) {
|
|
finalAudiobook = null
|
|
scanResult = ScanResult.NOTHING
|
|
} else {
|
|
scanResult = ScanResult.ADDED
|
|
}
|
|
}
|
|
|
|
// Scan for cover if enabled and has no cover
|
|
if (finalAudiobook && scannerFindCovers && !finalAudiobook.cover) {
|
|
if (finalAudiobook.book.shouldSearchForCover) {
|
|
var updatedCover = await this.searchForCover(finalAudiobook)
|
|
|
|
finalAudiobook.book.updateLastCoverSearch(updatedCover)
|
|
|
|
if (updatedCover && scanResult === ScanResult.UPTODATE) {
|
|
scanResult = ScanResult.UPDATED
|
|
}
|
|
await this.db.updateAudiobook(finalAudiobook)
|
|
this.emitter('audiobook_updated', finalAudiobook.toJSONMinified())
|
|
} else {
|
|
Logger.debug(`[Scanner] Audiobook "${finalAudiobook.title}" cover already scanned - not re-scanning`)
|
|
}
|
|
}
|
|
|
|
return scanResult
|
|
}
|
|
|
|
async scan(libraryId, forceAudioFileScan = false) {
|
|
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 scanPayload = {
|
|
id: libraryId,
|
|
name: library.name,
|
|
folders: library.folders.length
|
|
}
|
|
this.emitter('scan_start', scanPayload)
|
|
Logger.info(`[Scanner] Starting scan of library "${library.name}" with ${library.folders.length} folders`)
|
|
|
|
library.lastScan = Date.now()
|
|
await this.db.updateEntity('library', library)
|
|
|
|
this.librariesScanning.push(scanPayload)
|
|
|
|
var audiobooksInLibrary = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
|
|
|
// TODO: This temporary fix from pre-release should be removed soon, "checkUpdateInos"
|
|
if (audiobooksInLibrary.length) {
|
|
for (let i = 0; i < audiobooksInLibrary.length; i++) {
|
|
var ab = audiobooksInLibrary[i]
|
|
// Update ino if inos are not set
|
|
var shouldUpdateIno = ab.hasMissingIno
|
|
if (shouldUpdateIno) {
|
|
var filesWithMissingIno = ab.getFilesWithMissingIno()
|
|
|
|
Logger.debug(`\nUpdating inos for "${ab.title}"`)
|
|
Logger.debug(`In Scan, Files with missing inode`, filesWithMissingIno)
|
|
|
|
var hasUpdates = await ab.checkUpdateInos()
|
|
if (hasUpdates) {
|
|
await this.db.updateAudiobook(ab)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const scanStart = Date.now()
|
|
var audiobookDataFound = []
|
|
for (let i = 0; i < library.folders.length; i++) {
|
|
var folder = library.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)
|
|
|
|
if (this.cancelLibraryScan[libraryId]) {
|
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
|
delete this.cancelLibraryScan[libraryId]
|
|
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
|
this.emitter('scan_complete', { id: libraryId, name: library.name, results: null })
|
|
return null
|
|
}
|
|
|
|
var scanResults = {
|
|
removed: 0,
|
|
updated: 0,
|
|
added: 0,
|
|
missing: 0
|
|
}
|
|
|
|
// Check for 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.isMissing = true
|
|
audiobook.lastUpdate = Date.now()
|
|
scanResults.missing++
|
|
await this.db.updateAudiobook(audiobook)
|
|
this.emitter('audiobook_updated', audiobook.toJSONMinified())
|
|
}
|
|
if (this.cancelLibraryScan[libraryId]) {
|
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
|
delete this.cancelLibraryScan[libraryId]
|
|
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
|
this.emitter('scan_complete', { id: libraryId, name: library.name, results: scanResults })
|
|
return
|
|
}
|
|
}
|
|
|
|
// Check for new and updated audiobooks
|
|
for (let i = 0; i < audiobookDataFound.length; i++) {
|
|
var result = await this.scanAudiobookData(audiobookDataFound[i], forceAudioFileScan)
|
|
if (result === ScanResult.ADDED) scanResults.added++
|
|
if (result === ScanResult.REMOVED) scanResults.removed++
|
|
if (result === ScanResult.UPDATED) scanResults.updated++
|
|
|
|
var progress = Math.round(100 * (i + 1) / audiobookDataFound.length)
|
|
this.emitter('scan_progress', {
|
|
id: libraryId,
|
|
name: library.name,
|
|
progress: {
|
|
total: audiobookDataFound.length,
|
|
done: i + 1,
|
|
progress
|
|
}
|
|
})
|
|
if (this.cancelLibraryScan[libraryId]) {
|
|
Logger.info(`[Scanner] Canceling scan ${libraryId}`)
|
|
delete this.cancelLibraryScan[libraryId]
|
|
break
|
|
}
|
|
}
|
|
const scanElapsed = Math.floor((Date.now() - scanStart) / 1000)
|
|
Logger.info(`[Scanned] Finished | ${scanResults.added} added | ${scanResults.updated} updated | ${scanResults.removed} removed | ${scanResults.missing} missing | elapsed: ${secondsToTimestamp(scanElapsed)}`)
|
|
this.librariesScanning = this.librariesScanning.filter(l => l.id !== libraryId)
|
|
this.emitter('scan_complete', { id: libraryId, name: library.name, results: scanResults })
|
|
}
|
|
|
|
async scanAudiobookById(audiobookId) {
|
|
const audiobook = this.db.audiobooks.find(ab => ab.id === audiobookId)
|
|
if (!audiobook) {
|
|
Logger.error(`[Scanner] Scan audiobook by id not found ${audiobookId}`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
const library = this.db.libraries.find(lib => lib.id === audiobook.libraryId)
|
|
if (!library) {
|
|
Logger.error(`[Scanner] Scan audiobook by id library not found "${audiobook.libraryId}"`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
const folder = library.folders.find(f => f.id === audiobook.folderId)
|
|
if (!folder) {
|
|
Logger.error(`[Scanner] Scan audiobook by id folder not found "${audiobook.folderId}" in library "${library.name}"`)
|
|
return ScanResult.NOTHING
|
|
}
|
|
if (!folder.libraryId) {
|
|
Logger.fatal(`[Scanner] Folder does not have a library id set...`, folder)
|
|
return ScanResult.NOTHING
|
|
}
|
|
|
|
Logger.info(`[Scanner] Scanning Audiobook "${audiobook.title}"`)
|
|
return this.scanAudiobook(folder, audiobook.fullPath, true)
|
|
}
|
|
|
|
async scanAudiobook(folder, audiobookFullPath, forceAudioFileScan = false) {
|
|
Logger.debug('[Scanner] scanAudiobook', audiobookFullPath)
|
|
var audiobookData = await getAudiobookFileData(folder, audiobookFullPath, this.db.serverSettings)
|
|
if (!audiobookData) {
|
|
return ScanResult.NOTHING
|
|
}
|
|
return this.scanAudiobookData(audiobookData, forceAudioFileScan)
|
|
}
|
|
|
|
async scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup) {
|
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
|
if (!library) {
|
|
Logger.error(`[Scanner] Library "${libraryId}" not found for scan library updates`)
|
|
return null
|
|
}
|
|
var folder = library.folders.find(f => f.id === folderId)
|
|
if (!folder) {
|
|
Logger.error(`[Scanner] Folder "${folderId}" not found in library "${library.name}" for scan library updates`)
|
|
return null
|
|
}
|
|
|
|
Logger.debug(`[Scanner] Scanning file update groups in folder "${folder.id}" of library "${library.name}"`)
|
|
|
|
var bookGroupingResults = {}
|
|
for (const bookDir in fileUpdateBookGroup) {
|
|
var fullPath = Path.posix.join(folder.fullPath.replace(/\\/g, '/'), bookDir)
|
|
|
|
// Check if book dir group is already an audiobook or in a subdir of an audiobook
|
|
var existingAudiobook = this.db.audiobooks.find(ab => fullPath.startsWith(ab.fullPath))
|
|
if (existingAudiobook) {
|
|
|
|
// Is the audiobook exactly - check if was deleted
|
|
if (existingAudiobook.fullPath === fullPath) {
|
|
var exists = await fs.pathExists(fullPath)
|
|
if (!exists) {
|
|
Logger.info(`[Scanner] Scanning file update group and audiobook was deleted "${existingAudiobook.title}" - marking as missing`)
|
|
existingAudiobook.isMissing = true
|
|
existingAudiobook.lastUpdate = Date.now()
|
|
await this.db.updateAudiobook(existingAudiobook)
|
|
this.emitter('audiobook_updated', existingAudiobook.toJSONMinified())
|
|
|
|
bookGroupingResults[bookDir] = ScanResult.REMOVED
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Scan audiobook for updates
|
|
Logger.debug(`[Scanner] Folder update for relative path "${bookDir}" is in audiobook "${existingAudiobook.title}" - scan for updates`)
|
|
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, existingAudiobook.fullPath)
|
|
continue;
|
|
}
|
|
|
|
// Check if an audiobook is a subdirectory of this dir
|
|
var childAudiobook = this.db.audiobooks.find(ab => ab.fullPath.startsWith(fullPath))
|
|
if (childAudiobook) {
|
|
Logger.warn(`[Scanner] Files were modified in a parent directory of an audiobook "${childAudiobook.title}" - ignoring`)
|
|
bookGroupingResults[bookDir] = ScanResult.NOTHING
|
|
continue;
|
|
}
|
|
|
|
Logger.debug(`[Scanner] Folder update group must be a new book "${bookDir}" in library "${library.name}"`)
|
|
bookGroupingResults[bookDir] = await this.scanAudiobook(folder, fullPath)
|
|
}
|
|
|
|
return bookGroupingResults
|
|
}
|
|
|
|
// Array of file update objects that may have been renamed, removed or added
|
|
async filesChanged(fileUpdates) {
|
|
if (!fileUpdates.length) return null
|
|
|
|
// Group files by folder
|
|
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]
|
|
}
|
|
}
|
|
})
|
|
|
|
const libraryScanResults = {}
|
|
|
|
// Group files by book
|
|
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 fileUpdateBookGroup = groupFilesIntoAudiobookPaths(relFilePaths, true)
|
|
var folderScanResults = await this.scanFolderUpdates(libraryId, folderId, fileUpdateBookGroup)
|
|
libraryScanResults[libraryId] = folderScanResults
|
|
}
|
|
|
|
Logger.debug(`[Scanner] Finished scanning file changes, results:`, libraryScanResults)
|
|
return libraryScanResults
|
|
}
|
|
|
|
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 find(req, res) {
|
|
var method = req.params.method
|
|
var query = req.query
|
|
|
|
var result = null
|
|
|
|
if (method === 'isbn') {
|
|
result = await this.bookFinder.findByISBN(query)
|
|
} else if (method === 'search') {
|
|
result = await this.bookFinder.search(query.provider, query.title, query.author || null)
|
|
}
|
|
|
|
res.json(result)
|
|
}
|
|
|
|
async findCovers(req, res) {
|
|
var query = req.query
|
|
var options = {
|
|
fallbackTitleOnly: !!query.fallbackTitleOnly
|
|
}
|
|
var result = await this.bookFinder.findCovers(query.provider, query.title, query.author || null, options)
|
|
res.json(result)
|
|
}
|
|
|
|
async fixDuplicateIds() {
|
|
var ids = {}
|
|
var audiobooksUpdated = 0
|
|
for (let i = 0; i < this.db.audiobooks.length; i++) {
|
|
var ab = this.db.audiobooks[i]
|
|
if (ids[ab.id]) {
|
|
var abCopy = new Audiobook(ab.toJSON())
|
|
abCopy.id = getId('ab')
|
|
if (abCopy.book.cover) {
|
|
abCopy.book.cover = abCopy.book.cover.replace(ab.id, abCopy.id)
|
|
}
|
|
Logger.warn('Found duplicate ID - updating from', ab.id, 'to', abCopy.id)
|
|
await this.db.removeEntity('audiobook', ab.id)
|
|
await this.db.insertEntity('audiobook', abCopy)
|
|
audiobooksUpdated++
|
|
} else {
|
|
ids[ab.id] = true
|
|
}
|
|
}
|
|
if (audiobooksUpdated) {
|
|
Logger.info(`[Scanner] Updated ${audiobooksUpdated} audiobook IDs`)
|
|
}
|
|
}
|
|
}
|
|
module.exports = Scanner |