You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Readarr/src/NzbDrone.Core/MediaFiles/RootFolderWatchingService.cs

312 lines
11 KiB

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Extensions;
using NzbDrone.Common.TPL;
using NzbDrone.Core.Configuration;
using NzbDrone.Core.Configuration.Events;
using NzbDrone.Core.Datastore.Events;
using NzbDrone.Core.Lifecycle;
using NzbDrone.Core.MediaFiles.Commands;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.RootFolders;
namespace NzbDrone.Core.MediaFiles
{
public interface IRootFolderWatchingService
{
void ReportFileSystemChangeBeginning(params string[] paths);
}
public sealed class RootFolderWatchingService : IRootFolderWatchingService,
IDisposable,
IHandle<ModelEvent<RootFolder>>,
IHandle<ApplicationStartedEvent>,
IHandle<ConfigSavedEvent>
{
private const int DEBOUNCE_TIMEOUT_SECONDS = 30;
private readonly ConcurrentDictionary<string, FileSystemWatcher> _fileSystemWatchers = new ConcurrentDictionary<string, FileSystemWatcher>();
private readonly ConcurrentDictionary<string, int> _tempIgnoredPaths = new ConcurrentDictionary<string, int>();
private readonly ConcurrentDictionary<string, string> _changedPaths = new ConcurrentDictionary<string, string>();
private readonly IRootFolderService _rootFolderService;
private readonly IManageCommandQueue _commandQueueManager;
private readonly IConfigService _configService;
private readonly Logger _logger;
private readonly Debouncer _scanDebouncer;
private bool _watchForChanges;
public RootFolderWatchingService(IRootFolderService rootFolderService,
IManageCommandQueue commandQueueManager,
IConfigService configService,
Logger logger)
{
_rootFolderService = rootFolderService;
_commandQueueManager = commandQueueManager;
_configService = configService;
_logger = logger;
_scanDebouncer = new Debouncer(ScanPending, TimeSpan.FromSeconds(DEBOUNCE_TIMEOUT_SECONDS), true);
}
public void Dispose()
{
foreach (var watcher in _fileSystemWatchers.Values)
{
DisposeWatcher(watcher, false);
}
}
public void ReportFileSystemChangeBeginning(params string[] paths)
{
foreach (var path in paths.Where(x => x.IsNotNullOrWhiteSpace()))
{
_logger.Trace($"reporting start of change to {path}");
_tempIgnoredPaths.AddOrUpdate(path.CleanFilePathBasic(), 1, (key, value) => value + 1);
}
}
public void Handle(ApplicationStartedEvent message)
{
_watchForChanges = _configService.WatchLibraryForChanges;
if (_watchForChanges)
{
_rootFolderService.All().ForEach(x => StartWatchingPath(x.Path));
}
}
public void Handle(ConfigSavedEvent message)
{
var oldWatch = _watchForChanges;
_watchForChanges = _configService.WatchLibraryForChanges;
if (_watchForChanges != oldWatch)
{
if (_watchForChanges)
{
_rootFolderService.All().ForEach(x => StartWatchingPath(x.Path));
}
else
{
_rootFolderService.All().ForEach(x => StopWatchingPath(x.Path));
}
}
}
public void Handle(ModelEvent<RootFolder> message)
{
if (message.Action == ModelAction.Created && _watchForChanges)
{
StartWatchingPath(message.Model.Path);
}
else if (message.Action == ModelAction.Deleted)
{
StopWatchingPath(message.Model.Path);
}
}
private void StartWatchingPath(string path)
{
// Already being watched
if (_fileSystemWatchers.ContainsKey(path))
{
return;
}
// Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel
Task.Run(() =>
{
try
{
var newWatcher = new FileSystemWatcher(path, "*")
{
IncludeSubdirectories = true,
InternalBufferSize = 65536,
NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite
};
newWatcher.Created += Watcher_Changed;
newWatcher.Deleted += Watcher_Changed;
newWatcher.Renamed += Watcher_Changed;
newWatcher.Changed += Watcher_Changed;
newWatcher.Error += Watcher_Error;
if (_fileSystemWatchers.TryAdd(path, newWatcher))
{
newWatcher.EnableRaisingEvents = true;
_logger.Info("Watching directory {0}", path);
}
else
{
DisposeWatcher(newWatcher, false);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Error watching path: {0}", path);
}
});
}
private void StopWatchingPath(string path)
{
if (_fileSystemWatchers.TryGetValue(path, out var watcher))
{
DisposeWatcher(watcher, true);
}
}
private void Watcher_Error(object sender, ErrorEventArgs e)
{
var ex = e.GetException();
var dw = (FileSystemWatcher)sender;
if (ex.GetType() == typeof(InternalBufferOverflowException))
{
_logger.Warn(ex, "The file system watcher experienced an internal buffer overflow for: {0}", dw.Path);
_changedPaths.TryAdd(dw.Path, dw.Path);
_scanDebouncer.Execute();
}
else
{
_logger.Error(ex, "Error in Directory watcher for: {0}" + dw.Path);
DisposeWatcher(dw, true);
}
}
private void Watcher_Changed(object sender, FileSystemEventArgs e)
{
try
{
var rootFolder = ((FileSystemWatcher)sender).Path;
var path = e.FullPath;
if (path.IsNullOrWhiteSpace())
{
throw new ArgumentNullException("path");
}
_changedPaths.TryAdd(path, rootFolder);
_scanDebouncer.Execute();
}
catch (Exception ex)
{
_logger.Error(ex, "Exception in ReportFileSystemChanged. Path: {0}", e.FullPath);
}
}
private void ScanPending()
{
var pairs = _changedPaths.ToArray();
_changedPaths.Clear();
var ignored = _tempIgnoredPaths.Keys.ToArray();
_tempIgnoredPaths.Clear();
var toScan = new HashSet<string>();
foreach (var item in pairs)
{
var path = item.Key.CleanFilePathBasic();
var rootFolder = item.Value;
if (!ShouldIgnoreChange(path, ignored))
{
_logger.Trace("Actioning change to {0}", path);
toScan.Add(rootFolder);
}
else
{
_logger.Trace("Ignoring change to {0}", path);
}
}
if (toScan.Any())
{
_commandQueueManager.Push(new RescanFoldersCommand(toScan.ToList(), FilterFilesType.Known, true, null));
}
}
private bool ShouldIgnoreChange(string cleanPath, string[] ignoredPaths)
{
var cleaned = cleanPath.CleanFilePathBasic();
// Skip partial/backup
if (cleanPath.EndsWith(".partial~") ||
cleanPath.EndsWith(".backup~"))
{
return true;
}
// only proceed for directories and files with music extensions
var extension = Path.GetExtension(cleaned);
if (extension.IsNullOrWhiteSpace() && !Directory.Exists(cleaned))
{
return true;
}
5 years ago
if (extension.IsNotNullOrWhiteSpace() && !MediaFileExtensions.AllExtensions.Contains(extension))
{
return true;
}
// If the parent of an ignored path has a change event, ignore that too
// Note that we can't afford to use the PathEquals or IsParentPath functions because
// these rely on disk access which is too slow when trying to handle many update events
return ignoredPaths.Any(i => i.Equals(cleaned, DiskProviderBase.PathStringComparison) ||
i.StartsWith(cleaned + Path.DirectorySeparatorChar, DiskProviderBase.PathStringComparison) ||
Path.GetDirectoryName(i).Equals(cleaned, DiskProviderBase.PathStringComparison));
}
private void DisposeWatcher(FileSystemWatcher watcher, bool removeFromList)
{
try
{
using (watcher)
{
_logger.Info("Stopping directory watching for path {0}", watcher.Path);
watcher.Created -= Watcher_Changed;
watcher.Deleted -= Watcher_Changed;
watcher.Renamed -= Watcher_Changed;
watcher.Changed -= Watcher_Changed;
watcher.Error -= Watcher_Error;
try
{
watcher.EnableRaisingEvents = false;
}
catch (InvalidOperationException)
{
// Seeing this under mono on linux sometimes
// Collection was modified; enumeration operation may not execute.
}
}
}
catch
{
// we don't care about exceptions disposing
}
finally
{
if (removeFromList)
{
_fileSystemWatchers.TryRemove(watcher.Path, out _);
}
}
}
}
}