#pragma warning disable CS1591 using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.IO { public class LibraryMonitor : ILibraryMonitor { private readonly ILogger _logger; private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; /// /// The file system watchers. /// private readonly ConcurrentDictionary _fileSystemWatchers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); /// /// The affected paths. /// private readonly List _activeRefreshers = new List(); /// /// A dynamic list of paths that should be ignored. Added to during our own file system modifications. /// private readonly ConcurrentDictionary _tempIgnoredPaths = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); private bool _disposed = false; /// /// Initializes a new instance of the class. /// /// The logger. /// The library manager. /// The configuration manager. /// The filesystem. public LibraryMonitor( ILogger logger, ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem) { _libraryManager = libraryManager; _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; } /// /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope. /// /// The path. private void TemporarilyIgnore(string path) { _tempIgnoredPaths[path] = path; } public void ReportFileSystemChangeBeginning(string path) { ArgumentException.ThrowIfNullOrEmpty(path); TemporarilyIgnore(path); } public async void ReportFileSystemChangeComplete(string path, bool refreshPath) { ArgumentException.ThrowIfNullOrEmpty(path); // This is an arbitrary amount of time, but delay it because file system writes often trigger events long after the file was actually written to. // Seeing long delays in some situations, especially over the network, sometimes up to 45 seconds // But if we make this delay too high, we risk missing legitimate changes, such as user adding a new file, or hand-editing metadata await Task.Delay(45000).ConfigureAwait(false); _tempIgnoredPaths.TryRemove(path, out _); if (refreshPath) { try { ReportFileSystemChanged(path); } catch (Exception ex) { _logger.LogError(ex, "Error in ReportFileSystemChanged for {Path}", path); } } } private bool IsLibraryMonitorEnabled(BaseItem item) { if (item is BasePluginFolder) { return false; } var options = _libraryManager.GetLibraryOptions(item); if (options is not null) { return options.EnableRealtimeMonitor; } return false; } public void Start() { _libraryManager.ItemAdded += OnLibraryManagerItemAdded; _libraryManager.ItemRemoved += OnLibraryManagerItemRemoved; var pathsToWatch = new List(); var paths = _libraryManager .RootFolder .Children .Where(IsLibraryMonitorEnabled) .OfType() .SelectMany(f => f.PhysicalLocations) .Distinct(StringComparer.OrdinalIgnoreCase) .Order(); foreach (var path in paths) { if (!ContainsParentFolder(pathsToWatch, path)) { pathsToWatch.Add(path); } } foreach (var path in pathsToWatch) { StartWatchingPath(path); } } private void StartWatching(BaseItem item) { if (IsLibraryMonitorEnabled(item)) { StartWatchingPath(item.Path); } } /// /// Handles the ItemRemoved event of the LibraryManager control. /// /// The source of the event. /// The instance containing the event data. private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { StopWatchingPath(e.Item.Path); } } /// /// Handles the ItemAdded event of the LibraryManager control. /// /// The source of the event. /// The instance containing the event data. private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { StartWatching(e.Item); } } /// /// Examine a list of strings assumed to be file paths to see if it contains a parent of /// the provided path. /// /// The LST. /// The path. /// true if [contains parent folder] [the specified LST]; otherwise, false. /// is null. private static bool ContainsParentFolder(IReadOnlyList lst, ReadOnlySpan path) { if (path.IsEmpty) { throw new ArgumentException("Path can't be empty", nameof(path)); } path = path.TrimEnd(Path.DirectorySeparatorChar); foreach (var str in lst) { // this should be a little quicker than examining each actual parent folder... var compare = str.AsSpan().TrimEnd(Path.DirectorySeparatorChar); if (path.Equals(compare, StringComparison.OrdinalIgnoreCase) || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar)) { return true; } } return false; } /// /// Starts the watching path. /// /// The path. private void StartWatchingPath(string path) { if (!Directory.Exists(path)) { // Seeing a crash in the mono runtime due to an exception being thrown on a different thread _logger.LogInformation("Skipping realtime monitor for {Path} because the path does not exist", path); return; } // 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.CreationTime | NotifyFilters.DirectoryName | NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.Attributes }; newWatcher.Created += OnWatcherChanged; newWatcher.Deleted += OnWatcherChanged; newWatcher.Renamed += OnWatcherChanged; newWatcher.Changed += OnWatcherChanged; newWatcher.Error += OnWatcherError; if (_fileSystemWatchers.TryAdd(path, newWatcher)) { newWatcher.EnableRaisingEvents = true; _logger.LogInformation("Watching directory {Path}", path); } else { DisposeWatcher(newWatcher, false); } } catch (Exception ex) { _logger.LogError(ex, "Error watching path: {Path}", path); } }); } /// /// Stops the watching path. /// /// The path. private void StopWatchingPath(string path) { if (_fileSystemWatchers.TryGetValue(path, out var watcher)) { DisposeWatcher(watcher, true); } } /// /// Disposes the watcher. /// private void DisposeWatcher(FileSystemWatcher watcher, bool removeFromList) { try { using (watcher) { _logger.LogInformation("Stopping directory watching for path {Path}", watcher.Path); watcher.Created -= OnWatcherChanged; watcher.Deleted -= OnWatcherChanged; watcher.Renamed -= OnWatcherChanged; watcher.Changed -= OnWatcherChanged; watcher.Error -= OnWatcherError; watcher.EnableRaisingEvents = false; } } finally { if (removeFromList) { RemoveWatcherFromList(watcher); } } } /// /// Removes the watcher from list. /// /// The watcher. private void RemoveWatcherFromList(FileSystemWatcher watcher) { _fileSystemWatchers.TryRemove(watcher.Path, out _); } /// /// Handles the Error event of the watcher control. /// /// The source of the event. /// The instance containing the event data. private void OnWatcherError(object sender, ErrorEventArgs e) { var ex = e.GetException(); var dw = (FileSystemWatcher)sender; _logger.LogError(ex, "Error in Directory watcher for: {Path}", dw.Path); DisposeWatcher(dw, true); } /// /// Handles the Changed event of the watcher control. /// /// The source of the event. /// The instance containing the event data. private void OnWatcherChanged(object sender, FileSystemEventArgs e) { try { ReportFileSystemChanged(e.FullPath); } catch (Exception ex) { _logger.LogError(ex, "Exception in ReportFileSystemChanged. Path: {FullPath}", e.FullPath); } } public void ReportFileSystemChanged(string path) { ArgumentException.ThrowIfNullOrEmpty(path); if (IgnorePatterns.ShouldIgnore(path)) { return; } // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { if (_fileSystem.AreEqual(i, path) || _fileSystem.ContainsSubPath(i, path)) { _logger.LogDebug("Ignoring change to {Path}", path); return; } // Go up a level var parent = Path.GetDirectoryName(i); if (!string.IsNullOrEmpty(parent) && _fileSystem.AreEqual(parent, path)) { _logger.LogDebug("Ignoring change to {Path}", path); return; } } CreateRefresher(path); } private void CreateRefresher(string path) { var parentPath = Path.GetDirectoryName(path); lock (_activeRefreshers) { foreach (var refresher in _activeRefreshers) { // Path is already being refreshed if (_fileSystem.AreEqual(path, refresher.Path)) { refresher.RestartTimer(); return; } // Parent folder is already being refreshed if (_fileSystem.ContainsSubPath(refresher.Path, path)) { refresher.AddPath(path); return; } // New path is a parent if (_fileSystem.ContainsSubPath(path, refresher.Path)) { refresher.ResetPath(path, null); return; } // They are siblings. Rebase the refresher to the parent folder. if (parentPath is not null && Path.GetDirectoryName(refresher.Path.AsSpan()).Equals(parentPath, StringComparison.Ordinal)) { refresher.ResetPath(parentPath, path); return; } } var newRefresher = new FileRefresher(path, _configurationManager, _libraryManager, _logger); newRefresher.Completed += OnNewRefresherCompleted; _activeRefreshers.Add(newRefresher); } } private void OnNewRefresherCompleted(object? sender, EventArgs e) { if (sender is null) { return; } var refresher = (FileRefresher)sender; DisposeRefresher(refresher); } /// /// Stops this instance. /// public void Stop() { _libraryManager.ItemAdded -= OnLibraryManagerItemAdded; _libraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; foreach (var watcher in _fileSystemWatchers.Values.ToList()) { DisposeWatcher(watcher, false); } _fileSystemWatchers.Clear(); DisposeRefreshers(); } private void DisposeRefresher(FileRefresher refresher) { lock (_activeRefreshers) { refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); _activeRefreshers.Remove(refresher); } } private void DisposeRefreshers() { lock (_activeRefreshers) { foreach (var refresher in _activeRefreshers) { refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); } _activeRefreshers.Clear(); } } /// /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources. /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and - optionally - managed resources. /// /// true to release both managed and unmanaged resources; false to release only unmanaged resources. protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { Stop(); } _disposed = true; } } }