Simplify Folder Watcher (#1484)
* Refactored Library Watcher to use Hangfire under the hood. * Support .kavitaignore at root level. * Refactored a lot of the library watching code to process faster and handle when FileSystemWatcher runs out of internal buffer space. It's still not perfect, but good enough for basic use. * Make folder watching as experimental and default it to off by default. * Revert #1479 * Tweaked the messaging for OPDS to remove a note about download role. Moved some code closer to where it's used. * Cleaned up how the events widget reports * Fixed a null issue when deleting series in the UI * Cleaned up some debug code * Added more information for when we skip a scan * Cleaned up some logging messages in CoverGen tasks * More log message tweaks * Added some debug to help identify a rare issue * Fixed a bug where save bookmarks as webp could get reset to false when saving other server settings * Updated some documentation on library watcher. * Make LibraryWatcher fire every 5 mins
This commit is contained in:
parent
b64ed6df8d
commit
b07aaf1eb5
19 changed files with 187 additions and 259 deletions
|
|
@ -1,8 +1,8 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using Hangfire;
|
||||
|
|
@ -11,6 +11,52 @@ using Microsoft.Extensions.Logging;
|
|||
|
||||
namespace API.Services.Tasks.Scanner;
|
||||
|
||||
/// <summary>
|
||||
/// Change information
|
||||
/// </summary>
|
||||
public class Change
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the type of the change.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The type of the change.
|
||||
/// </value>
|
||||
public WatcherChangeTypes ChangeType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the full path.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The full path.
|
||||
/// </value>
|
||||
public string FullPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the name.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The name.
|
||||
/// </value>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the old full path.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The old full path.
|
||||
/// </value>
|
||||
public string OldFullPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the old name.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The old name.
|
||||
/// </value>
|
||||
public string OldName { get; set; }
|
||||
}
|
||||
|
||||
public interface ILibraryWatcher
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -29,29 +75,6 @@ public interface ILibraryWatcher
|
|||
Task RestartWatching();
|
||||
}
|
||||
|
||||
internal class FolderScanQueueable
|
||||
{
|
||||
public DateTime QueueTime { get; set; }
|
||||
public string FolderPath { get; set; }
|
||||
}
|
||||
|
||||
internal class FolderScanQueueableComparer : IEqualityComparer<FolderScanQueueable>
|
||||
{
|
||||
public bool Equals(FolderScanQueueable x, FolderScanQueueable y)
|
||||
{
|
||||
if (ReferenceEquals(x, y)) return true;
|
||||
if (ReferenceEquals(x, null)) return false;
|
||||
if (ReferenceEquals(y, null)) return false;
|
||||
if (x.GetType() != y.GetType()) return false;
|
||||
return x.FolderPath == y.FolderPath;
|
||||
}
|
||||
|
||||
public int GetHashCode(FolderScanQueueable obj)
|
||||
{
|
||||
return HashCode.Combine(obj.FolderPath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for watching the file system and processing change events. This is mainly responsible for invoking
|
||||
/// Scanner to quickly pickup on changes.
|
||||
|
|
@ -64,11 +87,13 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
private readonly IScannerService _scannerService;
|
||||
|
||||
private readonly Dictionary<string, IList<FileSystemWatcher>> _watcherDictionary = new ();
|
||||
/// <summary>
|
||||
/// This is just here to prevent GC from Disposing our watchers
|
||||
/// </summary>
|
||||
private readonly IList<FileSystemWatcher> _fileWatchers = new List<FileSystemWatcher>();
|
||||
private IList<string> _libraryFolders = new List<string>();
|
||||
|
||||
private readonly Queue<FolderScanQueueable> _scanQueue = new Queue<FolderScanQueueable>();
|
||||
private readonly TimeSpan _queueWaitTime;
|
||||
private readonly FolderScanQueueableComparer _folderScanQueueableComparer = new FolderScanQueueableComparer();
|
||||
|
||||
|
||||
public LibraryWatcher(IDirectoryService directoryService, IUnitOfWork unitOfWork, ILogger<LibraryWatcher> logger, IScannerService scannerService, IHostEnvironment environment)
|
||||
|
|
@ -78,7 +103,7 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
_logger = logger;
|
||||
_scannerService = scannerService;
|
||||
|
||||
_queueWaitTime = environment.IsDevelopment() ? TimeSpan.FromSeconds(10) : TimeSpan.FromMinutes(1);
|
||||
_queueWaitTime = environment.IsDevelopment() ? TimeSpan.FromSeconds(30) : TimeSpan.FromMinutes(5);
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -95,20 +120,16 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
{
|
||||
_logger.LogDebug("Watching {FolderPath}", libraryFolder);
|
||||
var watcher = new FileSystemWatcher(libraryFolder);
|
||||
watcher.NotifyFilter = NotifyFilters.CreationTime
|
||||
| NotifyFilters.DirectoryName
|
||||
| NotifyFilters.FileName
|
||||
| NotifyFilters.LastWrite
|
||||
| NotifyFilters.Size;
|
||||
|
||||
watcher.Changed += OnChanged;
|
||||
watcher.Created += OnCreated;
|
||||
watcher.Deleted += OnDeleted;
|
||||
watcher.Renamed += OnRenamed;
|
||||
watcher.Error += OnError;
|
||||
|
||||
watcher.Filter = "*.*";
|
||||
watcher.IncludeSubdirectories = true;
|
||||
watcher.EnableRaisingEvents = true;
|
||||
_fileWatchers.Add(watcher);
|
||||
if (!_watcherDictionary.ContainsKey(libraryFolder))
|
||||
{
|
||||
_watcherDictionary.Add(libraryFolder, new List<FileSystemWatcher>());
|
||||
|
|
@ -127,9 +148,9 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
fileSystemWatcher.Changed -= OnChanged;
|
||||
fileSystemWatcher.Created -= OnCreated;
|
||||
fileSystemWatcher.Deleted -= OnDeleted;
|
||||
fileSystemWatcher.Renamed -= OnRenamed;
|
||||
fileSystemWatcher.Dispose();
|
||||
}
|
||||
_fileWatchers.Clear();
|
||||
_watcherDictionary.Clear();
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +164,7 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
{
|
||||
if (e.ChangeType != WatcherChangeTypes.Changed) return;
|
||||
_logger.LogDebug("[LibraryWatcher] Changed: {FullPath}, {Name}", e.FullPath, e.Name);
|
||||
ProcessChange(e.FullPath);
|
||||
ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)));
|
||||
}
|
||||
|
||||
private void OnCreated(object sender, FileSystemEventArgs e)
|
||||
|
|
@ -152,87 +173,77 @@ public class LibraryWatcher : ILibraryWatcher
|
|||
ProcessChange(e.FullPath, !_directoryService.FileSystem.File.Exists(e.Name));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// From testing, on Deleted only needs to pass through the event when a folder is deleted. If a file is deleted, Changed will handle automatically.
|
||||
/// </summary>
|
||||
/// <param name="sender"></param>
|
||||
/// <param name="e"></param>
|
||||
private void OnDeleted(object sender, FileSystemEventArgs e) {
|
||||
var isDirectory = string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name));
|
||||
if (!isDirectory) return;
|
||||
_logger.LogDebug("[LibraryWatcher] Deleted: {FullPath}, {Name}", e.FullPath, e.Name);
|
||||
|
||||
// On deletion, we need another type of check. We need to check if e.Name has an extension or not
|
||||
// NOTE: File deletion will trigger a folder change event, so this might not be needed
|
||||
ProcessChange(e.FullPath, string.IsNullOrEmpty(_directoryService.FileSystem.Path.GetExtension(e.Name)));
|
||||
ProcessChange(e.FullPath, true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void OnRenamed(object sender, RenamedEventArgs e)
|
||||
private void OnError(object sender, ErrorEventArgs e)
|
||||
{
|
||||
_logger.LogDebug($"[LibraryWatcher] Renamed:");
|
||||
_logger.LogDebug(" Old: {OldFullPath}", e.OldFullPath);
|
||||
_logger.LogDebug(" New: {FullPath}", e.FullPath);
|
||||
ProcessChange(e.FullPath, _directoryService.FileSystem.Directory.Exists(e.FullPath));
|
||||
_logger.LogError(e.GetException(), "[LibraryWatcher] An error occured, likely too many watches occured at once. Restarting Watchers");
|
||||
Task.Run(RestartWatching);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Processes the file or folder change.
|
||||
/// Processes the file or folder change. If the change is a file change and not from a supported extension, it will be ignored.
|
||||
/// </summary>
|
||||
/// <remarks>This will ignore image files that are added to the system. However, they may still trigger scans due to folder changes.</remarks>
|
||||
/// <param name="filePath">File or folder that changed</param>
|
||||
/// <param name="isDirectoryChange">If the change is on a directory and not a file</param>
|
||||
private void ProcessChange(string filePath, bool isDirectoryChange = false)
|
||||
{
|
||||
// We need to check if directory or not
|
||||
if (!isDirectoryChange && !new Regex(Parser.Parser.SupportedExtensions).IsMatch(new FileInfo(filePath).Extension)) return;
|
||||
|
||||
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(parentDirectory)) return;
|
||||
|
||||
// We need to find the library this creation belongs to
|
||||
// Multiple libraries can point to the same base folder. In this case, we need use FirstOrDefault
|
||||
var libraryFolder = _libraryFolders.FirstOrDefault(f => parentDirectory.Contains(f));
|
||||
if (string.IsNullOrEmpty(libraryFolder)) return;
|
||||
|
||||
var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList();
|
||||
if (!rootFolder.Any()) return;
|
||||
|
||||
// Select the first folder and join with library folder, this should give us the folder to scan.
|
||||
var fullPath = Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder.First()));
|
||||
var queueItem = new FolderScanQueueable()
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
FolderPath = fullPath,
|
||||
QueueTime = DateTime.Now
|
||||
};
|
||||
if (!_scanQueue.Contains(queueItem, _folderScanQueueableComparer))
|
||||
{
|
||||
_logger.LogDebug("[LibraryWatcher] Queuing job for {Folder} at {TimeStamp}", fullPath, DateTime.Now);
|
||||
_scanQueue.Enqueue(queueItem);
|
||||
}
|
||||
// We need to check if directory or not
|
||||
if (!isDirectoryChange &&
|
||||
!(Parser.Parser.IsArchive(filePath) || Parser.Parser.IsBook(filePath))) return;
|
||||
|
||||
ProcessQueue();
|
||||
}
|
||||
var parentDirectory = _directoryService.GetParentDirectoryName(filePath);
|
||||
if (string.IsNullOrEmpty(parentDirectory)) return;
|
||||
|
||||
/// <summary>
|
||||
/// Instead of making things complicated with a separate thread, this service will process the queue whenever a change occurs
|
||||
/// </summary>
|
||||
private void ProcessQueue()
|
||||
{
|
||||
var i = 0;
|
||||
while (i < _scanQueue.Count)
|
||||
{
|
||||
var item = _scanQueue.Peek();
|
||||
if (item.QueueTime < DateTime.Now.Subtract(_queueWaitTime))
|
||||
// We need to find the library this creation belongs to
|
||||
// Multiple libraries can point to the same base folder. In this case, we need use FirstOrDefault
|
||||
var libraryFolder = _libraryFolders.FirstOrDefault(f => parentDirectory.Contains(f));
|
||||
if (string.IsNullOrEmpty(libraryFolder)) return;
|
||||
|
||||
var rootFolder = _directoryService.GetFoldersTillRoot(libraryFolder, filePath).ToList();
|
||||
if (!rootFolder.Any()) return;
|
||||
|
||||
// Select the first folder and join with library folder, this should give us the folder to scan.
|
||||
var fullPath =
|
||||
Parser.Parser.NormalizePath(_directoryService.FileSystem.Path.Join(libraryFolder, rootFolder.First()));
|
||||
|
||||
var alreadyScheduled =
|
||||
TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "ScanFolder", new object[] {fullPath});
|
||||
_logger.LogDebug("{FullPath} already enqueued: {Value}", fullPath, alreadyScheduled);
|
||||
if (!alreadyScheduled)
|
||||
{
|
||||
_logger.LogDebug("[LibraryWatcher] Scheduling ScanSeriesFolder for {Folder}", item.FolderPath);
|
||||
BackgroundJob.Enqueue(() => _scannerService.ScanFolder(item.FolderPath));
|
||||
_scanQueue.Dequeue();
|
||||
_logger.LogDebug("[LibraryWatcher] Scheduling ScanFolder for {Folder}", fullPath);
|
||||
BackgroundJob.Schedule(() => _scannerService.ScanFolder(fullPath), _queueWaitTime);
|
||||
}
|
||||
else
|
||||
{
|
||||
i++;
|
||||
_logger.LogDebug("[LibraryWatcher] Skipped scheduling ScanFolder for {Folder} as a job already queued",
|
||||
fullPath);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (_scanQueue.Count > 0)
|
||||
catch (Exception ex)
|
||||
{
|
||||
Task.Delay(TimeSpan.FromSeconds(30)).ContinueWith(t=> ProcessQueue());
|
||||
_logger.LogError(ex, "[LibraryWatcher] An error occured when processing a watch event");
|
||||
}
|
||||
|
||||
_logger.LogDebug("ProcessChange occured in {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue