DirectoryWatchers.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530
  1. using MediaBrowser.Common.IO;
  2. using MediaBrowser.Common.Logging;
  3. using MediaBrowser.Controller.Entities;
  4. using MediaBrowser.Controller.Library;
  5. using MediaBrowser.Controller.ScheduledTasks;
  6. using MediaBrowser.Model.Logging;
  7. using System;
  8. using System.Collections.Concurrent;
  9. using System.Collections.Generic;
  10. using System.IO;
  11. using System.Linq;
  12. using System.Threading;
  13. using System.Threading.Tasks;
  14. namespace MediaBrowser.Controller.IO
  15. {
  16. /// <summary>
  17. /// Class DirectoryWatchers
  18. /// </summary>
  19. public class DirectoryWatchers : IDisposable
  20. {
  21. /// <summary>
  22. /// The file system watchers
  23. /// </summary>
  24. private ConcurrentBag<FileSystemWatcher> FileSystemWatchers = new ConcurrentBag<FileSystemWatcher>();
  25. /// <summary>
  26. /// The update timer
  27. /// </summary>
  28. private Timer updateTimer;
  29. /// <summary>
  30. /// The affected paths
  31. /// </summary>
  32. private readonly ConcurrentDictionary<string, string> affectedPaths = new ConcurrentDictionary<string, string>();
  33. /// <summary>
  34. /// A dynamic list of paths that should be ignored. Added to during our own file sytem modifications.
  35. /// </summary>
  36. private readonly ConcurrentDictionary<string,string> TempIgnoredPaths = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  37. /// <summary>
  38. /// The timer lock
  39. /// </summary>
  40. private readonly object timerLock = new object();
  41. /// <summary>
  42. /// Add the path to our temporary ignore list. Use when writing to a path within our listening scope.
  43. /// </summary>
  44. /// <param name="path">The path.</param>
  45. public void TemporarilyIgnore(string path)
  46. {
  47. TempIgnoredPaths[path] = path;
  48. }
  49. /// <summary>
  50. /// Removes the temp ignore.
  51. /// </summary>
  52. /// <param name="path">The path.</param>
  53. public void RemoveTempIgnore(string path)
  54. {
  55. string val;
  56. TempIgnoredPaths.TryRemove(path, out val);
  57. }
  58. /// <summary>
  59. /// Gets or sets the logger.
  60. /// </summary>
  61. /// <value>The logger.</value>
  62. private ILogger Logger { get; set; }
  63. /// <summary>
  64. /// Initializes a new instance of the <see cref="DirectoryWatchers" /> class.
  65. /// </summary>
  66. public DirectoryWatchers(ILogger logger)
  67. {
  68. if (logger == null)
  69. {
  70. throw new ArgumentNullException("logger");
  71. }
  72. Logger = logger;
  73. }
  74. /// <summary>
  75. /// Starts this instance.
  76. /// </summary>
  77. internal void Start()
  78. {
  79. Kernel.Instance.LibraryManager.LibraryChanged += Instance_LibraryChanged;
  80. var pathsToWatch = new List<string> { Kernel.Instance.RootFolder.Path };
  81. var paths = Kernel.Instance.RootFolder.Children.OfType<Folder>()
  82. .SelectMany(f =>
  83. {
  84. try
  85. {
  86. // Accessing ResolveArgs could involve file system access
  87. return f.ResolveArgs.PhysicalLocations;
  88. }
  89. catch (IOException)
  90. {
  91. return new string[] {};
  92. }
  93. })
  94. .Where(Path.IsPathRooted);
  95. foreach (var path in paths)
  96. {
  97. if (!ContainsParentFolder(pathsToWatch, path))
  98. {
  99. pathsToWatch.Add(path);
  100. }
  101. }
  102. foreach (var path in pathsToWatch)
  103. {
  104. StartWatchingPath(path);
  105. }
  106. }
  107. /// <summary>
  108. /// Examine a list of strings assumed to be file paths to see if it contains a parent of
  109. /// the provided path.
  110. /// </summary>
  111. /// <param name="lst">The LST.</param>
  112. /// <param name="path">The path.</param>
  113. /// <returns><c>true</c> if [contains parent folder] [the specified LST]; otherwise, <c>false</c>.</returns>
  114. /// <exception cref="System.ArgumentNullException">path</exception>
  115. private static bool ContainsParentFolder(IEnumerable<string> lst, string path)
  116. {
  117. if (string.IsNullOrEmpty(path))
  118. {
  119. throw new ArgumentNullException("path");
  120. }
  121. path = path.TrimEnd(Path.DirectorySeparatorChar);
  122. return lst.Any(str =>
  123. {
  124. //this should be a little quicker than examining each actual parent folder...
  125. var compare = str.TrimEnd(Path.DirectorySeparatorChar);
  126. return (path.Equals(compare, StringComparison.OrdinalIgnoreCase) || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar));
  127. });
  128. }
  129. /// <summary>
  130. /// Starts the watching path.
  131. /// </summary>
  132. /// <param name="path">The path.</param>
  133. private void StartWatchingPath(string path)
  134. {
  135. // Creating a FileSystemWatcher over the LAN can take hundreds of milliseconds, so wrap it in a Task to do them all in parallel
  136. Task.Run(() =>
  137. {
  138. var newWatcher = new FileSystemWatcher(path, "*") { IncludeSubdirectories = true, InternalBufferSize = 32767 };
  139. newWatcher.Created += watcher_Changed;
  140. newWatcher.Deleted += watcher_Changed;
  141. newWatcher.Renamed += watcher_Changed;
  142. newWatcher.Changed += watcher_Changed;
  143. newWatcher.Error += watcher_Error;
  144. try
  145. {
  146. newWatcher.EnableRaisingEvents = true;
  147. FileSystemWatchers.Add(newWatcher);
  148. Logger.Info("Watching directory " + path);
  149. }
  150. catch (IOException ex)
  151. {
  152. Logger.ErrorException("Error watching path: {0}", ex, path);
  153. }
  154. catch (PlatformNotSupportedException ex)
  155. {
  156. Logger.ErrorException("Error watching path: {0}", ex, path);
  157. }
  158. });
  159. }
  160. /// <summary>
  161. /// Stops the watching path.
  162. /// </summary>
  163. /// <param name="path">The path.</param>
  164. private void StopWatchingPath(string path)
  165. {
  166. var watcher = FileSystemWatchers.FirstOrDefault(f => f.Path.Equals(path, StringComparison.OrdinalIgnoreCase));
  167. if (watcher != null)
  168. {
  169. DisposeWatcher(watcher);
  170. }
  171. }
  172. /// <summary>
  173. /// Disposes the watcher.
  174. /// </summary>
  175. /// <param name="watcher">The watcher.</param>
  176. private void DisposeWatcher(FileSystemWatcher watcher)
  177. {
  178. Logger.Info("Stopping directory watching for path {0}", watcher.Path);
  179. watcher.EnableRaisingEvents = false;
  180. watcher.Dispose();
  181. var watchers = FileSystemWatchers.ToList();
  182. watchers.Remove(watcher);
  183. FileSystemWatchers = new ConcurrentBag<FileSystemWatcher>(watchers);
  184. }
  185. /// <summary>
  186. /// Handles the LibraryChanged event of the Kernel
  187. /// </summary>
  188. /// <param name="sender">The source of the event.</param>
  189. /// <param name="e">The <see cref="Library.ChildrenChangedEventArgs" /> instance containing the event data.</param>
  190. void Instance_LibraryChanged(object sender, ChildrenChangedEventArgs e)
  191. {
  192. if (e.Folder is AggregateFolder && e.HasAddOrRemoveChange)
  193. {
  194. if (e.ItemsRemoved != null)
  195. {
  196. foreach (var item in e.ItemsRemoved.OfType<Folder>())
  197. {
  198. StopWatchingPath(item.Path);
  199. }
  200. }
  201. if (e.ItemsAdded != null)
  202. {
  203. foreach (var item in e.ItemsAdded.OfType<Folder>())
  204. {
  205. StartWatchingPath(item.Path);
  206. }
  207. }
  208. }
  209. }
  210. /// <summary>
  211. /// Handles the Error event of the watcher control.
  212. /// </summary>
  213. /// <param name="sender">The source of the event.</param>
  214. /// <param name="e">The <see cref="ErrorEventArgs" /> instance containing the event data.</param>
  215. async void watcher_Error(object sender, ErrorEventArgs e)
  216. {
  217. var ex = e.GetException();
  218. var dw = (FileSystemWatcher) sender;
  219. Logger.ErrorException("Error in Directory watcher for: "+dw.Path, ex);
  220. if (ex.Message.Contains("network name is no longer available"))
  221. {
  222. //Network either dropped or, we are coming out of sleep and it hasn't reconnected yet - wait and retry
  223. Logger.Warn("Network connection lost - will retry...");
  224. var retries = 0;
  225. var success = false;
  226. while (!success && retries < 10)
  227. {
  228. await Task.Delay(500).ConfigureAwait(false);
  229. try
  230. {
  231. dw.EnableRaisingEvents = false;
  232. dw.EnableRaisingEvents = true;
  233. success = true;
  234. }
  235. catch (IOException)
  236. {
  237. Logger.Warn("Network still unavailable...");
  238. retries++;
  239. }
  240. }
  241. if (!success)
  242. {
  243. Logger.Warn("Unable to access network. Giving up.");
  244. DisposeWatcher(dw);
  245. }
  246. }
  247. else
  248. {
  249. if (!ex.Message.Contains("BIOS command limit"))
  250. {
  251. Logger.Info("Attempting to re-start watcher.");
  252. dw.EnableRaisingEvents = false;
  253. dw.EnableRaisingEvents = true;
  254. }
  255. }
  256. }
  257. /// <summary>
  258. /// Handles the Changed event of the watcher control.
  259. /// </summary>
  260. /// <param name="sender">The source of the event.</param>
  261. /// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing the event data.</param>
  262. void watcher_Changed(object sender, FileSystemEventArgs e)
  263. {
  264. if (e.ChangeType == WatcherChangeTypes.Created && e.Name == "New folder")
  265. {
  266. return;
  267. }
  268. if (TempIgnoredPaths.ContainsKey(e.FullPath))
  269. {
  270. Logger.Info("Watcher requested to ignore change to " + e.FullPath);
  271. return;
  272. }
  273. Logger.Info("Watcher sees change of type " + e.ChangeType.ToString() + " to " + e.FullPath);
  274. //Since we're watching created, deleted and renamed we always want the parent of the item to be the affected path
  275. var affectedPath = e.FullPath;
  276. affectedPaths.AddOrUpdate(affectedPath, affectedPath, (key, oldValue) => affectedPath);
  277. lock (timerLock)
  278. {
  279. if (updateTimer == null)
  280. {
  281. updateTimer = new Timer(TimerStopped, null, TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1));
  282. }
  283. else
  284. {
  285. updateTimer.Change(TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1));
  286. }
  287. }
  288. }
  289. /// <summary>
  290. /// Timers the stopped.
  291. /// </summary>
  292. /// <param name="stateInfo">The state info.</param>
  293. private async void TimerStopped(object stateInfo)
  294. {
  295. lock (timerLock)
  296. {
  297. // Extend the timer as long as any of the paths are still being written to.
  298. if (affectedPaths.Any(p => IsFileLocked(p.Key)))
  299. {
  300. Logger.Info("Timer extended.");
  301. updateTimer.Change(TimeSpan.FromSeconds(Kernel.Instance.Configuration.FileWatcherDelay), TimeSpan.FromMilliseconds(-1));
  302. return;
  303. }
  304. Logger.Info("Timer stopped.");
  305. updateTimer.Dispose();
  306. updateTimer = null;
  307. }
  308. var paths = affectedPaths.Keys.ToList();
  309. affectedPaths.Clear();
  310. await ProcessPathChanges(paths).ConfigureAwait(false);
  311. }
  312. /// <summary>
  313. /// Try and determine if a file is locked
  314. /// This is not perfect, and is subject to race conditions, so I'd rather not make this a re-usable library method.
  315. /// </summary>
  316. /// <param name="path">The path.</param>
  317. /// <returns><c>true</c> if [is file locked] [the specified path]; otherwise, <c>false</c>.</returns>
  318. private bool IsFileLocked(string path)
  319. {
  320. try
  321. {
  322. var data = FileSystem.GetFileData(path);
  323. if (!data.HasValue || data.Value.IsDirectory)
  324. {
  325. return false;
  326. }
  327. }
  328. catch (IOException)
  329. {
  330. return false;
  331. }
  332. FileStream stream = null;
  333. try
  334. {
  335. stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite);
  336. }
  337. catch
  338. {
  339. //the file is unavailable because it is:
  340. //still being written to
  341. //or being processed by another thread
  342. //or does not exist (has already been processed)
  343. return true;
  344. }
  345. finally
  346. {
  347. if (stream != null)
  348. stream.Close();
  349. }
  350. //file is not locked
  351. return false;
  352. }
  353. /// <summary>
  354. /// Processes the path changes.
  355. /// </summary>
  356. /// <param name="paths">The paths.</param>
  357. /// <returns>Task.</returns>
  358. private async Task ProcessPathChanges(List<string> paths)
  359. {
  360. var itemsToRefresh = paths.Select(Path.GetDirectoryName)
  361. .Select(GetAffectedBaseItem)
  362. .Where(item => item != null)
  363. .Distinct()
  364. .ToList();
  365. foreach (var p in paths) Logger.Info(p + " reports change.");
  366. // If the root folder changed, run the library task so the user can see it
  367. if (itemsToRefresh.Any(i => i is AggregateFolder))
  368. {
  369. Kernel.Instance.TaskManager.CancelIfRunningAndQueue<RefreshMediaLibraryTask>();
  370. return;
  371. }
  372. await Task.WhenAll(itemsToRefresh.Select(i => Task.Run(async () =>
  373. {
  374. Logger.Info(i.Name + " (" + i.Path + ") will be refreshed.");
  375. try
  376. {
  377. await i.ChangedExternally().ConfigureAwait(false);
  378. }
  379. catch (IOException ex)
  380. {
  381. // For now swallow and log.
  382. // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable)
  383. // Should we remove it from it's parent?
  384. Logger.ErrorException("Error refreshing {0}", ex, i.Name);
  385. }
  386. }))).ConfigureAwait(false);
  387. }
  388. /// <summary>
  389. /// Gets the affected base item.
  390. /// </summary>
  391. /// <param name="path">The path.</param>
  392. /// <returns>BaseItem.</returns>
  393. private BaseItem GetAffectedBaseItem(string path)
  394. {
  395. BaseItem item = null;
  396. while (item == null && !string.IsNullOrEmpty(path))
  397. {
  398. item = Kernel.Instance.RootFolder.FindByPath(path);
  399. path = Path.GetDirectoryName(path);
  400. }
  401. if (item != null)
  402. {
  403. // If the item has been deleted find the first valid parent that still exists
  404. while (!Directory.Exists(item.Path) && !File.Exists(item.Path))
  405. {
  406. item = item.Parent;
  407. if (item == null)
  408. {
  409. break;
  410. }
  411. }
  412. }
  413. return item;
  414. }
  415. /// <summary>
  416. /// Stops this instance.
  417. /// </summary>
  418. private void Stop()
  419. {
  420. Kernel.Instance.LibraryManager.LibraryChanged -= Instance_LibraryChanged;
  421. FileSystemWatcher watcher;
  422. while (FileSystemWatchers.TryTake(out watcher))
  423. {
  424. watcher.Changed -= watcher_Changed;
  425. watcher.EnableRaisingEvents = false;
  426. watcher.Dispose();
  427. }
  428. lock (timerLock)
  429. {
  430. if (updateTimer != null)
  431. {
  432. updateTimer.Dispose();
  433. updateTimer = null;
  434. }
  435. }
  436. affectedPaths.Clear();
  437. }
  438. /// <summary>
  439. /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
  440. /// </summary>
  441. public void Dispose()
  442. {
  443. Dispose(true);
  444. GC.SuppressFinalize(this);
  445. }
  446. /// <summary>
  447. /// Releases unmanaged and - optionally - managed resources.
  448. /// </summary>
  449. /// <param name="dispose"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
  450. protected virtual void Dispose(bool dispose)
  451. {
  452. if (dispose)
  453. {
  454. Stop();
  455. }
  456. }
  457. }
  458. }