New: Watch filesystem for changes to library

pull/1689/head
ta264 4 years ago committed by Qstick
parent 87d29ec978
commit a2ba8e76bb

@ -256,6 +256,22 @@ class MediaManagement extends Component {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
size={sizes.MEDIUM}
>
<FormLabel>Watch Root Folders for file changes</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="watchLibraryForChanges"
helpText="Rescan automatically when files change in a root folder"
onChange={onInputChange}
{...settings.watchLibraryForChanges}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}

@ -14,6 +14,7 @@ namespace Lidarr.Api.V1.Config
public bool CreateEmptyArtistFolders { get; set; }
public bool DeleteEmptyFolders { get; set; }
public FileDateType FileDate { get; set; }
public bool WatchLibraryForChanges { get; set; }
public RescanAfterRefreshType RescanAfterRefresh { get; set; }
public AllowFingerprinting AllowFingerprinting { get; set; }
@ -43,6 +44,7 @@ namespace Lidarr.Api.V1.Config
CreateEmptyArtistFolders = model.CreateEmptyArtistFolders,
DeleteEmptyFolders = model.DeleteEmptyFolders,
FileDate = model.FileDate,
WatchLibraryForChanges = model.WatchLibraryForChanges,
RescanAfterRefresh = model.RescanAfterRefresh,
AllowFingerprinting = model.AllowFingerprinting,

@ -38,6 +38,28 @@ namespace NzbDrone.Common.Test.TPLTests
counter.Count.Should().Be(1);
}
[Test]
[Retry(3)]
public void should_wait_for_last_call_if_execute_resets_timer()
{
var counter = new Counter();
var debounceFunction = new Debouncer(counter.Hit, TimeSpan.FromMilliseconds(200), true);
debounceFunction.Execute();
Thread.Sleep(100);
debounceFunction.Execute();
Thread.Sleep(150);
counter.Count.Should().Be(0);
Thread.Sleep(100);
counter.Count.Should().Be(1);
}
[Test]
[Retry(3)]
public void should_throttle_calls()

@ -32,19 +32,23 @@ namespace NzbDrone.Common.Extensions
Ensure.That(path, () => path).IsValidPath();
var info = new FileInfo(path.Trim());
return info.FullName.CleanFilePathBasic();
}
public static string CleanFilePathBasic(this string path)
{
//UNC
if (OsInfo.IsWindows && info.FullName.StartsWith(@"\\"))
if (OsInfo.IsWindows && path.StartsWith(@"\\"))
{
return info.FullName.TrimEnd('/', '\\', ' ');
return path.TrimEnd('/', '\\', ' ');
}
if (OsInfo.IsNotWindows && info.FullName.TrimEnd('/').Length == 0)
if (OsInfo.IsNotWindows && path.TrimEnd('/').Length == 0)
{
return "/";
}
return info.FullName.TrimEnd('/').Trim('\\', ' ');
return path.TrimEnd('/').Trim('\\', ' ');
}
public static bool PathNotEquals(this string firstPath, string secondPath, StringComparison? comparison = null)

@ -6,15 +6,17 @@ namespace NzbDrone.Common.TPL
{
private readonly Action _action;
private readonly System.Timers.Timer _timer;
private readonly bool _executeRestartsTimer;
private volatile int _paused;
private volatile bool _triggered;
public Debouncer(Action action, TimeSpan debounceDuration)
public Debouncer(Action action, TimeSpan debounceDuration, bool executeRestartsTimer = false)
{
_action = action;
_timer = new System.Timers.Timer(debounceDuration.TotalMilliseconds);
_timer.Elapsed += timer_Elapsed;
_executeRestartsTimer = executeRestartsTimer;
}
private void timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
@ -32,6 +34,11 @@ namespace NzbDrone.Common.TPL
lock (_timer)
{
_triggered = true;
if (_executeRestartsTimer)
{
_timer.Stop();
}
if (_paused == 0)
{
_timer.Start();

@ -227,6 +227,13 @@ namespace NzbDrone.Core.Configuration
set { SetValue("ExtraFileExtensions", value); }
}
public bool WatchLibraryForChanges
{
get { return GetValueBoolean("WatchLibraryForChanges", true); }
set { SetValue("WatchLibraryForChanges", value); }
}
public RescanAfterRefreshType RescanAfterRefresh
{
get { return GetValueEnum("RescanAfterRefresh", RescanAfterRefreshType.Always); }

@ -36,6 +36,7 @@ namespace NzbDrone.Core.Configuration
bool CopyUsingHardlinks { get; set; }
bool ImportExtraFiles { get; set; }
string ExtraFileExtensions { get; set; }
bool WatchLibraryForChanges { get; set; }
RescanAfterRefreshType RescanAfterRefresh { get; set; }
AllowFingerprinting AllowFingerprinting { get; set; }

@ -39,6 +39,7 @@ namespace NzbDrone.Core.MediaFiles
private readonly IConfigService _configService;
private readonly IMediaFileService _mediaFileService;
private readonly IDiskProvider _diskProvider;
private readonly IRootFolderWatchingService _rootFolderWatchingService;
private readonly IArtistService _artistService;
private readonly IMapCoversToLocal _mediaCoverService;
private readonly IEventAggregator _eventAggregator;
@ -47,6 +48,7 @@ namespace NzbDrone.Core.MediaFiles
public AudioTagService(IConfigService configService,
IMediaFileService mediaFileService,
IDiskProvider diskProvider,
IRootFolderWatchingService rootFolderWatchingService,
IArtistService artistService,
IMapCoversToLocal mediaCoverService,
IEventAggregator eventAggregator,
@ -55,6 +57,7 @@ namespace NzbDrone.Core.MediaFiles
_configService = configService;
_mediaFileService = mediaFileService;
_diskProvider = diskProvider;
_rootFolderWatchingService = rootFolderWatchingService;
_artistService = artistService;
_mediaCoverService = mediaCoverService;
_eventAggregator = eventAggregator;
@ -186,6 +189,7 @@ namespace NzbDrone.Core.MediaFiles
tags.MusicBrainzAlbumComment = null;
tags.MusicBrainzReleaseTrackId = null;
_rootFolderWatchingService.ReportFileSystemChangeBeginning(path);
tags.Write(path);
}
@ -211,6 +215,8 @@ namespace NzbDrone.Core.MediaFiles
var diff = ReadAudioTag(path).Diff(newTags);
_rootFolderWatchingService.ReportFileSystemChangeBeginning(path);
if (_configService.ScrubAudioTags)
{
_logger.Debug($"Scrubbing tags for {trackfile}");
@ -218,6 +224,7 @@ namespace NzbDrone.Core.MediaFiles
}
_logger.Debug($"Writing tags for {trackfile}");
newTags.Write(path);
UpdateTrackfileSizeAndModified(trackfile, path);

@ -34,6 +34,9 @@ namespace NzbDrone.Core.MediaFiles
IDiskScanService,
IExecute<RescanFoldersCommand>
{
public static readonly Regex ExcludedSubFoldersRegex = new Regex(@"(?:\\|\/|^)(?:extras|@eadir|extrafanart|plex versions|\.[^\\/]+)(?:\\|\/)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static readonly Regex ExcludedFilesRegex = new Regex(@"^\._|^Thumbs\.db$|^\.DS_store$|\.partial~$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private readonly IDiskProvider _diskProvider;
private readonly IMediaFileService _mediaFileService;
private readonly IMakeImportDecision _importDecisionMaker;
@ -65,9 +68,6 @@ namespace NzbDrone.Core.MediaFiles
_logger = logger;
}
private static readonly Regex ExcludedSubFoldersRegex = new Regex(@"(?:\\|\/|^)(?:extras|@eadir|extrafanart|plex versions|\.[^\\/]+)(?:\\|\/)", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ExcludedFilesRegex = new Regex(@"^\._|^Thumbs\.db$|^\.DS_store$|\.partial~$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public void Scan(List<string> folders = null, FilterFilesType filter = FilterFilesType.Known, List<int> artistIds = null)
{
if (folders == null)

@ -0,0 +1,311 @@
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;
}
if (extension.IsNotNullOrWhiteSpace() && !MediaFileExtensions.Extensions.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 _);
}
}
}
}
}

@ -30,21 +30,23 @@ namespace NzbDrone.Core.MediaFiles
private readonly IBuildFileNames _buildFileNames;
private readonly IDiskTransferService _diskTransferService;
private readonly IDiskProvider _diskProvider;
private readonly IRootFolderWatchingService _rootFolderWatchingService;
private readonly IMediaFileAttributeService _mediaFileAttributeService;
private readonly IEventAggregator _eventAggregator;
private readonly IConfigService _configService;
private readonly Logger _logger;
public TrackFileMovingService(ITrackService trackService,
IAlbumService albumService,
IUpdateTrackFileService updateTrackFileService,
IBuildFileNames buildFileNames,
IDiskTransferService diskTransferService,
IDiskProvider diskProvider,
IMediaFileAttributeService mediaFileAttributeService,
IEventAggregator eventAggregator,
IConfigService configService,
Logger logger)
IAlbumService albumService,
IUpdateTrackFileService updateTrackFileService,
IBuildFileNames buildFileNames,
IDiskTransferService diskTransferService,
IDiskProvider diskProvider,
IRootFolderWatchingService rootFolderWatchingService,
IMediaFileAttributeService mediaFileAttributeService,
IEventAggregator eventAggregator,
IConfigService configService,
Logger logger)
{
_trackService = trackService;
_albumService = albumService;
@ -52,6 +54,7 @@ namespace NzbDrone.Core.MediaFiles
_buildFileNames = buildFileNames;
_diskTransferService = diskTransferService;
_diskProvider = diskProvider;
_rootFolderWatchingService = rootFolderWatchingService;
_mediaFileAttributeService = mediaFileAttributeService;
_eventAggregator = eventAggregator;
_configService = configService;
@ -119,6 +122,7 @@ namespace NzbDrone.Core.MediaFiles
throw new SameFilenameException("File not moved, source and destination are the same", trackFilePath);
}
_rootFolderWatchingService.ReportFileSystemChangeBeginning(trackFilePath, destinationFilePath);
_diskTransferService.TransferFile(trackFilePath, destinationFilePath, mode);
trackFile.Path = destinationFilePath;
@ -166,6 +170,8 @@ namespace NzbDrone.Core.MediaFiles
var changed = false;
var newEvent = new TrackFolderCreatedEvent(artist, trackFile);
_rootFolderWatchingService.ReportFileSystemChangeBeginning(artistFolder, albumFolder, trackFolder);
if (!_diskProvider.FolderExists(artistFolder))
{
CreateFolder(artistFolder);

@ -2,6 +2,7 @@ using System.IO;
using NLog;
using NzbDrone.Common.Disk;
using NzbDrone.Common.Instrumentation.Extensions;
using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Messaging.Commands;
using NzbDrone.Core.Messaging.Events;
using NzbDrone.Core.Music.Commands;
@ -15,6 +16,7 @@ namespace NzbDrone.Core.Music
private readonly IArtistService _artistService;
private readonly IBuildFileNames _filenameBuilder;
private readonly IDiskProvider _diskProvider;
private readonly IRootFolderWatchingService _rootFolderWatchingService;
private readonly IDiskTransferService _diskTransferService;
private readonly IEventAggregator _eventAggregator;
private readonly Logger _logger;
@ -22,6 +24,7 @@ namespace NzbDrone.Core.Music
public MoveArtistService(IArtistService artistService,
IBuildFileNames filenameBuilder,
IDiskProvider diskProvider,
IRootFolderWatchingService rootFolderWatchingService,
IDiskTransferService diskTransferService,
IEventAggregator eventAggregator,
Logger logger)
@ -29,6 +32,7 @@ namespace NzbDrone.Core.Music
_artistService = artistService;
_filenameBuilder = filenameBuilder;
_diskProvider = diskProvider;
_rootFolderWatchingService = rootFolderWatchingService;
_diskTransferService = diskTransferService;
_eventAggregator = eventAggregator;
_logger = logger;
@ -55,6 +59,8 @@ namespace NzbDrone.Core.Music
{
if (moveFiles)
{
_rootFolderWatchingService.ReportFileSystemChangeBeginning(sourcePath, destinationPath);
_diskTransferService.TransferFolder(sourcePath, destinationPath, TransferMode.Move);
_logger.ProgressInfo("{0} moved successfully to {1}", artist.Name, artist.Path);

Loading…
Cancel
Save