2023-09-03 00:49:28 +02:00
const sequelize = require ( 'sequelize' )
2023-08-26 23:33:27 +02:00
const Path = require ( 'path' )
const packageJson = require ( '../../package.json' )
const Logger = require ( '../Logger' )
const SocketAuthority = require ( '../SocketAuthority' )
const Database = require ( '../Database' )
const fs = require ( '../libs/fsExtra' )
const fileUtils = require ( '../utils/fileUtils' )
const scanUtils = require ( '../utils/scandir' )
2023-09-04 18:50:55 +02:00
const { LogLevel , ScanResult } = require ( '../utils/constants' )
2023-09-02 01:01:17 +02:00
const libraryFilters = require ( '../utils/queries/libraryFilters' )
2023-10-21 00:46:18 +02:00
const TaskManager = require ( '../managers/TaskManager' )
2023-09-04 00:51:58 +02:00
const LibraryItemScanner = require ( './LibraryItemScanner' )
2023-08-28 00:19:57 +02:00
const LibraryScan = require ( './LibraryScan' )
2023-08-26 23:33:27 +02:00
const LibraryItemScanData = require ( './LibraryItemScanData' )
2023-10-21 19:25:45 +02:00
const Task = require ( '../objects/Task' )
2023-08-26 23:33:27 +02:00
class LibraryScanner {
2023-09-04 00:51:58 +02:00
constructor ( ) {
2023-08-26 23:33:27 +02:00
this . cancelLibraryScan = { }
2024-09-21 21:02:57 +02:00
/** @type {string[]} - library ids */
2023-08-26 23:33:27 +02:00
this . librariesScanning = [ ]
2023-09-04 18:50:55 +02:00
this . scanningFilesChanged = false
2023-10-21 19:25:45 +02:00
/** @type {[import('../Watcher').PendingFileUpdate[], Task][]} */
2023-09-04 18:50:55 +02:00
this . pendingFileUpdatesToScan = [ ]
2023-08-26 23:33:27 +02:00
}
/ * *
2024-08-24 23:09:54 +02:00
* @ param { string } libraryId
2023-08-26 23:33:27 +02:00
* @ returns { boolean }
* /
isLibraryScanning ( libraryId ) {
2024-09-21 21:02:57 +02:00
return this . librariesScanning . some ( ( lid ) => lid === libraryId )
2023-08-26 23:33:27 +02:00
}
2023-09-04 18:50:55 +02:00
/ * *
2024-08-24 23:09:54 +02:00
*
* @ param { string } libraryId
2023-09-04 18:50:55 +02:00
* /
setCancelLibraryScan ( libraryId ) {
2024-09-21 21:02:57 +02:00
if ( ! this . isLibraryScanning ( libraryId ) ) return
2023-09-04 18:50:55 +02:00
this . cancelLibraryScan [ libraryId ] = true
}
2023-08-26 23:33:27 +02:00
/ * *
2024-08-24 23:09:54 +02:00
*
2024-08-29 00:26:23 +02:00
* @ param { import ( '../models/Library' ) } library
2024-08-24 23:09:54 +02:00
* @ param { boolean } [ forceRescan ]
2023-08-26 23:33:27 +02:00
* /
2023-10-09 00:10:43 +02:00
async scan ( library , forceRescan = false ) {
2023-08-26 23:33:27 +02:00
if ( this . isLibraryScanning ( library . id ) ) {
2023-10-10 00:48:21 +02:00
Logger . error ( ` [LibraryScanner] Already scanning ${ library . id } ` )
2023-08-26 23:33:27 +02:00
return
}
2024-08-29 00:26:23 +02:00
if ( ! library . libraryFolders . length ) {
2023-10-10 00:48:21 +02:00
Logger . warn ( ` [LibraryScanner] Library has no folders to scan " ${ library . name } " ` )
2023-08-26 23:33:27 +02:00
return
}
2024-09-17 23:10:32 +02:00
const metadataPrecedence = library . settings . metadataPrecedence || Database . libraryModel . defaultMetadataPrecedence
if ( library . isBook && metadataPrecedence . join ( ) !== library . lastScanMetadataPrecedence . join ( ) ) {
2023-10-10 00:48:21 +02:00
const lastScanMetadataPrecedence = library . lastScanMetadataPrecedence ? . join ( ) || 'Unset'
2024-09-17 23:10:32 +02:00
Logger . info ( ` [LibraryScanner] Library metadata precedence changed since last scan. From [ ${ lastScanMetadataPrecedence } ] to [ ${ metadataPrecedence . join ( ) } ] ` )
2023-10-10 00:48:21 +02:00
forceRescan = true
}
2023-08-26 23:33:27 +02:00
const libraryScan = new LibraryScan ( )
2023-09-04 20:59:37 +02:00
libraryScan . setData ( library )
2023-08-26 23:33:27 +02:00
libraryScan . verbose = true
2024-09-21 21:02:57 +02:00
this . librariesScanning . push ( libraryScan . libraryId )
2023-08-26 23:33:27 +02:00
2023-10-21 00:46:18 +02:00
const taskData = {
libraryId : library . id ,
libraryName : library . name ,
libraryMediaType : library . mediaType
}
2024-09-21 00:18:29 +02:00
const taskTitleString = {
text : ` Scanning " ${ library . name } " library ` ,
key : 'MessageTaskScanningLibrary' ,
subs : [ library . name ]
}
const task = TaskManager . createAndAddTask ( 'library-scan' , taskTitleString , null , true , taskData )
2023-08-26 23:33:27 +02:00
2023-10-10 00:48:21 +02:00
Logger . info ( ` [LibraryScanner] Starting ${ forceRescan ? ' (forced)' : '' } library scan ${ libraryScan . id } for ${ libraryScan . libraryName } ` )
2023-08-26 23:33:27 +02:00
2024-09-12 17:56:52 +02:00
try {
const canceled = await this . scanLibrary ( libraryScan , forceRescan )
libraryScan . setComplete ( )
2023-08-26 23:33:27 +02:00
2024-09-13 08:23:48 +02:00
Logger . info ( ` [LibraryScanner] Library scan " ${ libraryScan . id } " ${ canceled ? 'canceled after' : 'completed in' } ${ libraryScan . elapsedTimestamp } | ${ libraryScan . resultStats } ` )
2024-09-12 17:56:52 +02:00
2024-09-13 08:23:48 +02:00
if ( ! canceled ) {
library . lastScan = Date . now ( )
library . lastScanVersion = packageJson . version
if ( library . isBook ) {
const newExtraData = library . extraData || { }
2024-09-17 23:10:32 +02:00
newExtraData . lastScanMetadataPrecedence = metadataPrecedence
2024-09-13 08:23:48 +02:00
library . extraData = newExtraData
library . changed ( 'extraData' , true )
}
await library . save ( )
2024-09-12 17:56:52 +02:00
}
2023-08-26 23:33:27 +02:00
2024-09-21 21:02:57 +02:00
task . data . scanResults = libraryScan . scanResults
if ( canceled ) {
const taskFinishedString = {
text : 'Task canceled by user' ,
key : 'MessageTaskCanceledByUser'
}
task . setFinished ( taskFinishedString )
} else {
task . setFinished ( null , true )
}
2024-09-12 17:56:52 +02:00
} catch ( err ) {
2024-09-21 21:02:57 +02:00
libraryScan . setComplete ( )
2024-09-12 17:56:52 +02:00
2024-09-13 08:23:48 +02:00
Logger . error ( ` [LibraryScanner] Library scan ${ libraryScan . id } failed after ${ libraryScan . elapsedTimestamp } | ${ libraryScan . resultStats } . ` , err )
2024-09-12 17:56:52 +02:00
2024-09-21 21:02:57 +02:00
task . data . scanResults = libraryScan . scanResults
const taskFailedString = {
text : 'Failed' ,
key : 'MessageTaskFailed'
}
task . setFailed ( taskFailedString )
2023-10-10 00:48:21 +02:00
}
2024-09-13 08:23:48 +02:00
if ( this . cancelLibraryScan [ libraryScan . libraryId ] ) delete this . cancelLibraryScan [ libraryScan . libraryId ]
2024-09-21 21:02:57 +02:00
this . librariesScanning = this . librariesScanning . filter ( ( lid ) => lid !== library . id )
2024-09-12 17:56:52 +02:00
2023-10-21 00:46:18 +02:00
TaskManager . taskFinished ( task )
2023-08-26 23:33:27 +02:00
2024-09-13 08:23:48 +02:00
libraryScan . saveLog ( )
2023-08-26 23:33:27 +02:00
}
/ * *
2024-08-24 23:09:54 +02:00
*
* @ param { import ( './LibraryScan' ) } libraryScan
2023-10-09 00:10:43 +02:00
* @ param { boolean } forceRescan
* @ returns { Promise < boolean > } true if scan canceled
2023-08-26 23:33:27 +02:00
* /
2023-10-09 00:10:43 +02:00
async scanLibrary ( libraryScan , forceRescan ) {
2023-09-02 01:01:17 +02:00
// Make sure library filter data is set
// this is used to check for existing authors & series
2024-08-29 00:26:23 +02:00
await libraryFilters . getFilterData ( libraryScan . libraryMediaType , libraryScan . libraryId )
2023-09-02 01:01:17 +02:00
2023-08-26 23:33:27 +02:00
/** @type {LibraryItemScanData[]} */
let libraryItemDataFound = [ ]
// Scan each library folder
2024-08-29 00:26:23 +02:00
for ( let i = 0 ; i < libraryScan . libraryFolders . length ; i ++ ) {
const folder = libraryScan . libraryFolders [ i ]
2023-08-26 23:33:27 +02:00
const itemDataFoundInFolder = await this . scanFolder ( libraryScan . library , folder )
2024-08-29 00:26:23 +02:00
libraryScan . addLog ( LogLevel . INFO , ` ${ itemDataFoundInFolder . length } item data found in folder " ${ folder . path } " ` )
2023-08-26 23:33:27 +02:00
libraryItemDataFound = libraryItemDataFound . concat ( itemDataFoundInFolder )
}
2024-09-13 08:23:48 +02:00
if ( this . shouldCancelScan ( libraryScan ) ) return true
2023-08-26 23:33:27 +02:00
const existingLibraryItems = await Database . libraryItemModel . findAll ( {
where : {
libraryId : libraryScan . libraryId
2023-09-03 00:49:28 +02:00
}
2023-08-26 23:33:27 +02:00
} )
2024-09-13 08:23:48 +02:00
if ( this . shouldCancelScan ( libraryScan ) ) return true
2023-09-03 00:49:28 +02:00
2023-08-26 23:33:27 +02:00
const libraryItemIdsMissing = [ ]
2023-09-03 00:49:28 +02:00
let oldLibraryItemsUpdated = [ ]
2023-08-26 23:33:27 +02:00
for ( const existingLibraryItem of existingLibraryItems ) {
// First try to find matching library item with exact file path
2024-08-24 23:09:54 +02:00
let libraryItemData = libraryItemDataFound . find ( ( lid ) => lid . path === existingLibraryItem . path )
2023-08-26 23:33:27 +02:00
if ( ! libraryItemData ) {
// Fallback to finding matching library item with matching inode value
2024-08-24 23:09:54 +02:00
libraryItemData = libraryItemDataFound . find ( ( lid ) => ItemToItemInoMatch ( lid , existingLibraryItem ) || ItemToFileInoMatch ( lid , existingLibraryItem ) || ItemToFileInoMatch ( existingLibraryItem , lid ) )
2023-08-26 23:33:27 +02:00
if ( libraryItemData ) {
libraryScan . addLog ( LogLevel . INFO , ` Library item with path " ${ existingLibraryItem . path } " was not found, but library item inode " ${ existingLibraryItem . ino } " was found at path " ${ libraryItemData . path } " ` )
}
}
if ( ! libraryItemData ) {
// Podcast folder can have no episodes and still be valid
2024-08-24 23:09:54 +02:00
if ( libraryScan . libraryMediaType === 'podcast' && ( await fs . pathExists ( existingLibraryItem . path ) ) ) {
2023-08-26 23:33:27 +02:00
libraryScan . addLog ( LogLevel . INFO , ` Library item " ${ existingLibraryItem . relPath } " folder exists but has no episodes ` )
} else {
libraryScan . addLog ( LogLevel . WARN , ` Library Item " ${ existingLibraryItem . path } " (inode: ${ existingLibraryItem . ino } ) is missing ` )
2023-08-29 00:50:21 +02:00
libraryScan . resultsMissing ++
2023-08-26 23:33:27 +02:00
if ( ! existingLibraryItem . isMissing ) {
libraryItemIdsMissing . push ( existingLibraryItem . id )
2023-09-03 00:49:28 +02:00
// TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database . libraryItemModel . getOldById ( existingLibraryItem . id )
2024-06-27 22:32:14 +02:00
if ( oldLibraryItem ) {
oldLibraryItem . isMissing = true
oldLibraryItem . updatedAt = Date . now ( )
oldLibraryItemsUpdated . push ( oldLibraryItem )
}
2023-08-26 23:33:27 +02:00
}
}
} else {
2024-08-24 23:09:54 +02:00
libraryItemDataFound = libraryItemDataFound . filter ( ( lidf ) => lidf !== libraryItemData )
2023-10-09 00:10:43 +02:00
let libraryItemDataUpdated = await libraryItemData . checkLibraryItemData ( existingLibraryItem , libraryScan )
if ( libraryItemDataUpdated || forceRescan ) {
if ( forceRescan || libraryItemData . hasLibraryFileChanges || libraryItemData . hasPathChange ) {
const { libraryItem , wasUpdated } = await LibraryItemScanner . rescanLibraryItemMedia ( existingLibraryItem , libraryItemData , libraryScan . library . settings , libraryScan )
if ( ! forceRescan || wasUpdated ) {
libraryScan . resultsUpdated ++
const oldLibraryItem = Database . libraryItemModel . getOldLibraryItem ( libraryItem )
oldLibraryItemsUpdated . push ( oldLibraryItem )
} else {
libraryScan . addLog ( LogLevel . DEBUG , ` Library item " ${ existingLibraryItem . relPath } " is up-to-date ` )
}
2023-09-03 00:49:28 +02:00
} else {
2023-10-09 00:10:43 +02:00
libraryScan . resultsUpdated ++
2023-09-03 00:49:28 +02:00
// TODO: Temporary while using old model to socket emit
const oldLibraryItem = await Database . libraryItemModel . getOldById ( existingLibraryItem . id )
oldLibraryItemsUpdated . push ( oldLibraryItem )
}
2023-10-09 00:10:43 +02:00
} else {
libraryScan . addLog ( LogLevel . DEBUG , ` Library item " ${ existingLibraryItem . relPath } " is up-to-date ` )
2023-08-26 23:33:27 +02:00
}
}
2023-09-03 00:49:28 +02:00
// Emit item updates in chunks of 10 to client
if ( oldLibraryItemsUpdated . length === 10 ) {
// TODO: Should only emit to clients where library item is accessible
2024-08-24 23:09:54 +02:00
SocketAuthority . emitter (
'items_updated' ,
oldLibraryItemsUpdated . map ( ( li ) => li . toJSONExpanded ( ) )
)
2023-09-03 00:49:28 +02:00
oldLibraryItemsUpdated = [ ]
}
2024-09-13 08:23:48 +02:00
if ( this . shouldCancelScan ( libraryScan ) ) return true
2023-09-03 00:49:28 +02:00
}
// Emit item updates to client
if ( oldLibraryItemsUpdated . length ) {
// TODO: Should only emit to clients where library item is accessible
2024-08-24 23:09:54 +02:00
SocketAuthority . emitter (
'items_updated' ,
oldLibraryItemsUpdated . map ( ( li ) => li . toJSONExpanded ( ) )
)
2023-09-03 00:49:28 +02:00
}
2023-09-04 00:51:58 +02:00
// Authors and series that were removed from books should be removed if they are now empty
await LibraryItemScanner . checkAuthorsAndSeriesRemovedFromBooks ( libraryScan . libraryId , libraryScan )
2023-09-03 16:54:23 +02:00
2023-08-26 23:33:27 +02:00
// Update missing library items
if ( libraryItemIdsMissing . length ) {
libraryScan . addLog ( LogLevel . INFO , ` Updating ${ libraryItemIdsMissing . length } library items missing ` )
2024-08-24 23:09:54 +02:00
await Database . libraryItemModel . update (
{
isMissing : true ,
lastScan : Date . now ( ) ,
lastScanVersion : packageJson . version
} ,
{
where : {
id : libraryItemIdsMissing
}
2023-08-26 23:33:27 +02:00
}
2024-08-24 23:09:54 +02:00
)
2023-08-26 23:33:27 +02:00
}
2023-08-29 00:50:21 +02:00
2024-09-13 08:23:48 +02:00
if ( this . shouldCancelScan ( libraryScan ) ) return true
2023-09-03 00:49:28 +02:00
2023-08-29 00:50:21 +02:00
// Add new library items
if ( libraryItemDataFound . length ) {
2023-09-03 00:49:28 +02:00
let newOldLibraryItems = [ ]
2023-09-02 01:01:17 +02:00
for ( const libraryItemData of libraryItemDataFound ) {
2023-09-04 00:51:58 +02:00
const newLibraryItem = await LibraryItemScanner . scanNewLibraryItem ( libraryItemData , libraryScan . library . settings , libraryScan )
2023-09-02 01:01:17 +02:00
if ( newLibraryItem ) {
2023-09-03 00:49:28 +02:00
const oldLibraryItem = Database . libraryItemModel . getOldLibraryItem ( newLibraryItem )
newOldLibraryItems . push ( oldLibraryItem )
2023-09-02 01:01:17 +02:00
libraryScan . resultsAdded ++
}
2023-09-03 00:49:28 +02:00
// Emit new items in chunks of 10 to client
if ( newOldLibraryItems . length === 10 ) {
// TODO: Should only emit to clients where library item is accessible
2024-08-24 23:09:54 +02:00
SocketAuthority . emitter (
'items_added' ,
newOldLibraryItems . map ( ( li ) => li . toJSONExpanded ( ) )
)
2023-09-03 00:49:28 +02:00
newOldLibraryItems = [ ]
}
2024-09-13 08:23:48 +02:00
if ( this . shouldCancelScan ( libraryScan ) ) return true
2023-09-03 00:49:28 +02:00
}
// Emit new items to client
if ( newOldLibraryItems . length ) {
// TODO: Should only emit to clients where library item is accessible
2024-08-24 23:09:54 +02:00
SocketAuthority . emitter (
'items_added' ,
newOldLibraryItems . map ( ( li ) => li . toJSONExpanded ( ) )
)
2023-09-02 01:01:17 +02:00
}
2023-08-29 00:50:21 +02:00
}
2024-09-13 08:23:48 +02:00
libraryScan . addLog ( LogLevel . INFO , ` Scan completed. ${ libraryScan . resultStats } ` )
return false
}
shouldCancelScan ( libraryScan ) {
if ( this . cancelLibraryScan [ libraryScan . libraryId ] ) {
libraryScan . addLog ( LogLevel . INFO , ` Scan canceled. ${ libraryScan . resultStats } ` )
return true
}
return false
2023-08-26 23:33:27 +02:00
}
/ * *
* Get scan data for library folder
2024-08-29 00:26:23 +02:00
* @ param { import ( '../models/Library' ) } library
* @ param { import ( '../models/LibraryFolder' ) } folder
2023-08-26 23:33:27 +02:00
* @ returns { LibraryItemScanData [ ] }
* /
async scanFolder ( library , folder ) {
2024-08-29 00:26:23 +02:00
const folderPath = fileUtils . filePathToPOSIX ( folder . path )
2023-08-26 23:33:27 +02:00
const pathExists = await fs . pathExists ( folderPath )
if ( ! pathExists ) {
Logger . error ( ` [scandir] Invalid folder path does not exist " ${ folderPath } " ` )
return [ ]
}
const fileItems = await fileUtils . recurseFiles ( folderPath )
const libraryItemGrouping = scanUtils . groupFileItemsIntoLibraryItemDirs ( library . mediaType , fileItems , library . settings . audiobooksOnly )
if ( ! Object . keys ( libraryItemGrouping ) . length ) {
Logger . error ( ` Root path has no media folders: ${ folderPath } ` )
return [ ]
}
const items = [ ]
for ( const libraryItemPath in libraryItemGrouping ) {
let isFile = false // item is not in a folder
let libraryItemData = null
let fileObjs = [ ]
if ( libraryItemPath === libraryItemGrouping [ libraryItemPath ] ) {
// Media file in root only get title
libraryItemData = {
mediaMetadata : {
title : Path . basename ( libraryItemPath , Path . extname ( libraryItemPath ) )
} ,
path : Path . posix . join ( folderPath , libraryItemPath ) ,
relPath : libraryItemPath
}
fileObjs = await scanUtils . buildLibraryFile ( folderPath , [ libraryItemPath ] )
isFile = true
} else {
libraryItemData = scanUtils . getDataFromMediaDir ( library . mediaType , folderPath , libraryItemPath )
fileObjs = await scanUtils . buildLibraryFile ( libraryItemData . path , libraryItemGrouping [ libraryItemPath ] )
}
const libraryItemFolderStats = await fileUtils . getFileTimestampsWithIno ( libraryItemData . path )
if ( ! libraryItemFolderStats . ino ) {
Logger . warn ( ` [LibraryScanner] Library item folder " ${ libraryItemData . path } " has no inode value ` )
continue
}
2024-08-24 23:09:54 +02:00
items . push (
new LibraryItemScanData ( {
libraryFolderId : folder . id ,
libraryId : folder . libraryId ,
mediaType : library . mediaType ,
ino : libraryItemFolderStats . ino ,
mtimeMs : libraryItemFolderStats . mtimeMs || 0 ,
ctimeMs : libraryItemFolderStats . ctimeMs || 0 ,
birthtimeMs : libraryItemFolderStats . birthtimeMs || 0 ,
path : libraryItemData . path ,
relPath : libraryItemData . relPath ,
isFile ,
mediaMetadata : libraryItemData . mediaMetadata || null ,
libraryFiles : fileObjs
} )
)
2023-08-26 23:33:27 +02:00
}
return items
}
2023-09-04 18:50:55 +02:00
/ * *
* Scan files changed from Watcher
2024-08-24 23:09:54 +02:00
* @ param { import ( '../Watcher' ) . PendingFileUpdate [ ] } fileUpdates
2023-10-21 19:25:45 +02:00
* @ param { Task } pendingTask
2023-09-04 18:50:55 +02:00
* /
2023-10-21 19:25:45 +02:00
async scanFilesChanged ( fileUpdates , pendingTask ) {
2023-09-04 18:50:55 +02:00
if ( ! fileUpdates ? . length ) return
// If already scanning files from watcher then add these updates to queue
if ( this . scanningFilesChanged ) {
2023-10-21 19:25:45 +02:00
this . pendingFileUpdatesToScan . push ( [ fileUpdates , pendingTask ] )
2023-09-04 18:50:55 +02:00
Logger . debug ( ` [LibraryScanner] Already scanning files from watcher - file updates pushed to queue (size ${ this . pendingFileUpdatesToScan . length } ) ` )
return
}
this . scanningFilesChanged = true
2023-10-21 19:25:45 +02:00
const results = {
added : 0 ,
updated : 0 ,
removed : 0
}
2023-09-04 18:50:55 +02:00
// files grouped by folder
const folderGroups = this . getFileUpdatesGrouped ( fileUpdates )
for ( const folderId in folderGroups ) {
const libraryId = folderGroups [ folderId ] . libraryId
2024-08-24 23:09:54 +02:00
2023-09-04 18:50:55 +02:00
const library = await Database . libraryModel . findByPk ( libraryId , {
include : {
model : Database . libraryFolderModel ,
where : {
id : folderId
}
}
} )
if ( ! library ) {
Logger . error ( ` [LibraryScanner] Library " ${ libraryId } " not found in files changed ${ libraryId } ` )
continue
}
const folder = library . libraryFolders [ 0 ]
2024-08-24 23:09:54 +02:00
const relFilePaths = folderGroups [ folderId ] . fileUpdates . map ( ( fileUpdate ) => fileUpdate . relPath )
2023-09-04 18:50:55 +02:00
const fileUpdateGroup = scanUtils . groupFilesIntoLibraryItemPaths ( library . mediaType , relFilePaths )
if ( ! Object . keys ( fileUpdateGroup ) . length ) {
Logger . info ( ` [LibraryScanner] No important changes to scan for in folder " ${ folderId } " ` )
continue
}
const folderScanResults = await this . scanFolderUpdates ( library , folder , fileUpdateGroup )
Logger . debug ( ` [LibraryScanner] Folder scan results ` , folderScanResults )
2023-10-21 19:25:45 +02:00
// Tally results to share with client
let resetFilterData = false
Object . values ( folderScanResults ) . forEach ( ( scanResult ) => {
if ( scanResult === ScanResult . ADDED ) {
resetFilterData = true
results . added ++
} else if ( scanResult === ScanResult . REMOVED ) {
resetFilterData = true
results . removed ++
} else if ( scanResult === ScanResult . UPDATED ) {
resetFilterData = true
results . updated ++
}
} )
2023-09-04 18:50:55 +02:00
// If something was updated then reset numIssues filter data for library
2023-10-21 19:25:45 +02:00
if ( resetFilterData ) {
2023-09-04 18:50:55 +02:00
await Database . resetLibraryIssuesFilterData ( libraryId )
}
}
2023-10-21 19:25:45 +02:00
// Complete task and send results to client
const resultStrs = [ ]
if ( results . added ) resultStrs . push ( ` ${ results . added } added ` )
if ( results . updated ) resultStrs . push ( ` ${ results . updated } updated ` )
if ( results . removed ) resultStrs . push ( ` ${ results . removed } missing ` )
2024-09-21 21:02:57 +02:00
let scanResultStr = 'No changes needed'
2023-10-21 19:25:45 +02:00
if ( resultStrs . length ) scanResultStr = resultStrs . join ( ', ' )
2024-09-21 21:02:57 +02:00
pendingTask . data . scanResults = {
... results ,
text : scanResultStr ,
elapsed : Date . now ( ) - pendingTask . startedAt
}
pendingTask . setFinished ( null , true )
2023-10-21 19:25:45 +02:00
TaskManager . taskFinished ( pendingTask )
2023-09-04 18:50:55 +02:00
this . scanningFilesChanged = false
if ( this . pendingFileUpdatesToScan . length ) {
Logger . debug ( ` [LibraryScanner] File updates finished scanning with more updates in queue ( ${ this . pendingFileUpdatesToScan . length } ) ` )
2023-10-21 19:25:45 +02:00
this . scanFilesChanged ( ... this . pendingFileUpdatesToScan . shift ( ) )
2023-09-04 18:50:55 +02:00
}
}
/ * *
* Group array of PendingFileUpdate from Watcher by folder
2024-08-24 23:09:54 +02:00
* @ param { import ( '../Watcher' ) . PendingFileUpdate [ ] } fileUpdates
2023-09-04 18:50:55 +02:00
* @ returns { Record < string , { libraryId : string , folderId : string , fileUpdates : import ( '../Watcher' ) . PendingFileUpdate [ ] } > }
* /
getFileUpdatesGrouped ( fileUpdates ) {
const 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
}
/ * *
* Scan grouped paths for library folder coming from Watcher
2024-08-24 23:09:54 +02:00
* @ param { import ( '../models/Library' ) } library
* @ param { import ( '../models/LibraryFolder' ) } folder
* @ param { Record < string , string [ ] > } fileUpdateGroup
2023-09-04 18:50:55 +02:00
* @ returns { Promise < Record < string , number >> }
* /
async scanFolderUpdates ( library , folder , fileUpdateGroup ) {
// Make sure library filter data is set
// this is used to check for existing authors & series
await libraryFilters . getFilterData ( library . mediaType , library . id )
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
const updateGroup = { ... fileUpdateGroup }
for ( const itemDir in updateGroup ) {
2023-11-04 12:06:54 +01:00
if ( isSingleMediaFile ( fileUpdateGroup , itemDir ) ) continue // Media in root path
2023-09-04 18:50:55 +02:00
2024-08-24 23:09:54 +02:00
const itemDirNestedFiles = fileUpdateGroup [ itemDir ] . filter ( ( b ) => b . includes ( '/' ) )
2023-09-04 18:50:55 +02:00
if ( ! itemDirNestedFiles . length ) continue
const firstNest = itemDirNestedFiles [ 0 ] . split ( '/' ) . shift ( )
const altDir = ` ${ itemDir } / ${ firstNest } `
const fullPath = Path . posix . join ( fileUtils . filePathToPOSIX ( folder . path ) , itemDir )
const childLibraryItem = await Database . libraryItemModel . findOne ( {
attributes : [ 'id' , 'path' ] ,
where : {
path : {
[ sequelize . Op . not ] : fullPath
} ,
path : {
[ sequelize . Op . startsWith ] : fullPath
}
}
} )
if ( ! childLibraryItem ) {
continue
}
const altFullPath = Path . posix . join ( fileUtils . filePathToPOSIX ( folder . path ) , altDir )
const altChildLibraryItem = await Database . libraryItemModel . findOne ( {
attributes : [ 'id' , 'path' ] ,
where : {
path : {
[ sequelize . Op . not ] : altFullPath
} ,
path : {
[ sequelize . Op . startsWith ] : altFullPath
}
}
} )
if ( altChildLibraryItem ) {
continue
}
delete fileUpdateGroup [ itemDir ]
fileUpdateGroup [ altDir ] = itemDirNestedFiles . map ( ( f ) => f . split ( '/' ) . slice ( 1 ) . join ( '/' ) )
Logger . warn ( ` [LibraryScanner] Some files were modified in a parent directory of a library item " ${ childLibraryItem . path } " - ignoring ` )
}
// Second pass: Check for new/updated/removed items
const itemGroupingResults = { }
for ( const itemDir in fileUpdateGroup ) {
const fullPath = Path . posix . join ( fileUtils . filePathToPOSIX ( folder . path ) , itemDir )
const itemDirParts = itemDir . split ( '/' ) . slice ( 0 , - 1 )
2023-09-06 22:43:59 +02:00
const potentialChildDirs = [ fullPath ]
2023-09-04 18:50:55 +02:00
for ( let i = 0 ; i < itemDirParts . length ; i ++ ) {
2024-08-24 23:09:54 +02:00
potentialChildDirs . push (
Path . posix . join (
fileUtils . filePathToPOSIX ( folder . path ) ,
itemDir
. split ( '/' )
. slice ( 0 , - 1 - i )
. join ( '/' )
)
)
2023-09-04 18:50:55 +02:00
}
// Check if book dir group is already an item
let existingLibraryItem = await Database . libraryItemModel . findOneOld ( {
2024-03-23 17:31:52 +01:00
libraryId : library . id ,
2023-09-04 18:50:55 +02:00
path : potentialChildDirs
} )
2024-03-20 10:40:50 +01:00
let updatedLibraryItemDetails = { }
2023-09-04 18:50:55 +02:00
if ( ! existingLibraryItem ) {
2024-03-23 17:31:52 +01:00
const isSingleMedia = isSingleMediaFile ( fileUpdateGroup , itemDir )
2024-08-24 23:09:54 +02:00
existingLibraryItem = ( await findLibraryItemByItemToItemInoMatch ( library . id , fullPath ) ) || ( await findLibraryItemByItemToFileInoMatch ( library . id , fullPath , isSingleMedia ) ) || ( await findLibraryItemByFileToItemInoMatch ( library . id , fullPath , isSingleMedia , fileUpdateGroup [ itemDir ] ) )
2023-09-04 18:50:55 +02:00
if ( existingLibraryItem ) {
2023-09-04 20:59:37 +02:00
// Update library item paths for scan
2023-09-04 18:50:55 +02:00
existingLibraryItem . path = fullPath
existingLibraryItem . relPath = itemDir
2024-03-20 10:40:50 +01:00
updatedLibraryItemDetails . path = fullPath
updatedLibraryItemDetails . relPath = itemDir
updatedLibraryItemDetails . libraryFolderId = folder . id
2024-03-23 17:31:52 +01:00
updatedLibraryItemDetails . isFile = isSingleMedia
2023-09-04 18:50:55 +02:00
}
}
if ( existingLibraryItem ) {
// Is the item exactly - check if was deleted
if ( existingLibraryItem . path === fullPath ) {
const exists = await fs . pathExists ( fullPath )
if ( ! exists ) {
Logger . info ( ` [LibraryScanner] Scanning file update group and library item was deleted " ${ existingLibraryItem . media . metadata . title } " - marking as missing ` )
existingLibraryItem . setMissing ( )
await Database . updateLibraryItem ( existingLibraryItem )
SocketAuthority . emitter ( 'item_updated' , existingLibraryItem . toJSONExpanded ( ) )
itemGroupingResults [ itemDir ] = ScanResult . REMOVED
continue
}
}
// Scan library item for updates
Logger . debug ( ` [LibraryScanner] Folder update for relative path " ${ itemDir } " is in library item " ${ existingLibraryItem . media . metadata . title } " - scan for updates ` )
2024-03-20 10:40:50 +01:00
itemGroupingResults [ itemDir ] = await LibraryItemScanner . scanLibraryItem ( existingLibraryItem . id , updatedLibraryItemDetails )
2023-09-04 18:50:55 +02:00
continue
2023-11-04 12:06:54 +01:00
} else if ( library . settings . audiobooksOnly && ! hasAudioFiles ( fileUpdateGroup , itemDir ) ) {
2023-09-04 18:50:55 +02:00
Logger . debug ( ` [LibraryScanner] Folder update for relative path " ${ itemDir } " has no audio files ` )
continue
}
// Check if a library item is a subdirectory of this dir
const childItem = await Database . libraryItemModel . findOne ( {
attributes : [ 'id' , 'path' ] ,
where : {
path : {
[ sequelize . Op . startsWith ] : fullPath + '/'
}
}
} )
if ( childItem ) {
Logger . warn ( ` [LibraryScanner] Files were modified in a parent directory of a library item " ${ childItem . path } " - ignoring ` )
itemGroupingResults [ itemDir ] = ScanResult . NOTHING
continue
}
Logger . debug ( ` [LibraryScanner] Folder update group must be a new item " ${ itemDir } " in library " ${ library . name } " ` )
2023-11-04 12:06:54 +01:00
const isSingleMediaItem = isSingleMediaFile ( fileUpdateGroup , itemDir )
2023-09-04 18:50:55 +02:00
const newLibraryItem = await LibraryItemScanner . scanPotentialNewLibraryItem ( fullPath , library , folder , isSingleMediaItem )
if ( newLibraryItem ) {
const oldNewLibraryItem = Database . libraryItemModel . getOldLibraryItem ( newLibraryItem )
SocketAuthority . emitter ( 'item_added' , oldNewLibraryItem . toJSONExpanded ( ) )
}
itemGroupingResults [ itemDir ] = newLibraryItem ? ScanResult . ADDED : ScanResult . NOTHING
}
return itemGroupingResults
}
2023-08-26 23:33:27 +02:00
}
2023-11-04 12:06:54 +01:00
module . exports = new LibraryScanner ( )
2024-03-23 17:31:52 +01:00
function ItemToFileInoMatch ( libraryItem1 , libraryItem2 ) {
2024-08-24 23:09:54 +02:00
return libraryItem1 . isFile && libraryItem2 . libraryFiles . some ( ( lf ) => lf . ino === libraryItem1 . ino )
2024-03-23 17:31:52 +01:00
}
function ItemToItemInoMatch ( libraryItem1 , libraryItem2 ) {
return libraryItem1 . ino === libraryItem2 . ino
}
2023-11-04 12:06:54 +01:00
function hasAudioFiles ( fileUpdateGroup , itemDir ) {
2024-08-24 23:09:54 +02:00
return isSingleMediaFile ( fileUpdateGroup , itemDir ) ? scanUtils . checkFilepathIsAudioFile ( fileUpdateGroup [ itemDir ] ) : fileUpdateGroup [ itemDir ] . some ( scanUtils . checkFilepathIsAudioFile )
2023-11-04 12:06:54 +01:00
}
function isSingleMediaFile ( fileUpdateGroup , itemDir ) {
return itemDir === fileUpdateGroup [ itemDir ]
}
2024-03-23 17:31:52 +01:00
async function findLibraryItemByItemToItemInoMatch ( libraryId , fullPath ) {
const ino = await fileUtils . getIno ( fullPath )
if ( ! ino ) return null
const existingLibraryItem = await Database . libraryItemModel . findOneOld ( {
libraryId : libraryId ,
ino : ino
} )
2024-08-24 23:09:54 +02:00
if ( existingLibraryItem ) Logger . debug ( ` [LibraryScanner] Found library item with matching inode " ${ ino } " at path " ${ existingLibraryItem . path } " ` )
2024-03-23 17:31:52 +01:00
return existingLibraryItem
}
2024-03-23 20:56:32 +01:00
async function findLibraryItemByItemToFileInoMatch ( libraryId , fullPath , isSingleMedia ) {
2024-03-23 17:31:52 +01:00
if ( ! isSingleMedia ) return null
// check if it was moved from another folder by comparing the ino to the library files
const ino = await fileUtils . getIno ( fullPath )
if ( ! ino ) return null
2024-08-24 23:09:54 +02:00
const existingLibraryItem = await Database . libraryItemModel . findOneOld (
[
{
libraryId : libraryId
} ,
sequelize . where ( sequelize . literal ( '(SELECT count(*) FROM json_each(libraryFiles) WHERE json_valid(json_each.value) AND json_each.value->>"$.ino" = :inode)' ) , {
[ sequelize . Op . gt ] : 0
} )
] ,
2024-03-23 20:56:32 +01:00
{
2024-08-24 23:09:54 +02:00
inode : ino
}
)
if ( existingLibraryItem ) Logger . debug ( ` [LibraryScanner] Found library item with a library file matching inode " ${ ino } " at path " ${ existingLibraryItem . path } " ` )
2024-03-23 17:31:52 +01:00
return existingLibraryItem
}
async function findLibraryItemByFileToItemInoMatch ( libraryId , fullPath , isSingleMedia , itemFiles ) {
if ( isSingleMedia ) return null
// check if it was moved from the root folder by comparing the ino to the ino of the scanned files
let itemFileInos = [ ]
for ( const itemFile of itemFiles ) {
const ino = await fileUtils . getIno ( Path . posix . join ( fullPath , itemFile ) )
if ( ino ) itemFileInos . push ( ino )
}
if ( ! itemFileInos . length ) return null
const existingLibraryItem = await Database . libraryItemModel . findOneOld ( {
libraryId : libraryId ,
ino : {
2024-03-23 20:56:32 +01:00
[ sequelize . Op . in ] : itemFileInos
2024-03-23 17:31:52 +01:00
}
} )
2024-08-24 23:09:54 +02:00
if ( existingLibraryItem ) Logger . debug ( ` [LibraryScanner] Found library item with inode matching one of " ${ itemFileInos . join ( ',' ) } " at path " ${ existingLibraryItem . path } " ` )
2024-03-23 17:31:52 +01:00
return existingLibraryItem
2024-08-24 23:09:54 +02:00
}