diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-09-02 07:06:08 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-09-02 07:06:08 +0200 |
| commit | d73e3d964e3ce8197ec86bdaad447072305bc0d6 (patch) | |
| tree | ac6e3add8f901f4b17fe5815fbfc30832235d955 | |
| parent | e5dc3b8a54fdad218ba062f8adb58fb5e42d8f9d (diff) | |
Optimize Caches
Co-Authored-By: Cody Robibero <cody@robibe.ro>
8 files changed, 236 insertions, 31 deletions
diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 1bf0f8c76c..bc76c99451 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; @@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO private readonly ILibraryManager _libraryManager; private readonly IServerConfigurationManager _configurationManager; private readonly IFileSystem _fileSystem; + private readonly IDirectoryService _directoryService; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; /// <summary> @@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO /// <param name="libraryManager">The library manager.</param> /// <param name="configurationManager">The configuration manager.</param> /// <param name="fileSystem">The filesystem.</param> + /// <param name="directoryService">The directory service.</param> /// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param> /// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param> public LibraryMonitor( @@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO ILibraryManager libraryManager, IServerConfigurationManager configurationManager, IFileSystem fileSystem, + IDirectoryService directoryService, IHostApplicationLifetime appLifetime, DotIgnoreIgnoreRule dotIgnoreIgnoreRule) { @@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; + _directoryService = directoryService; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; appLifetime.ApplicationStarted.Register(Start); @@ -363,6 +368,12 @@ namespace Emby.Server.Implementations.IO return; } + // Something changed on disk that the server did not necessarily do itself, so whatever is + // cached about that path and its folder is now a guess. This sits above the checks below + // because a change we deliberately do not refresh for still has to be read correctly the + // next time somebody looks at that folder. + _directoryService.Invalidate(path); + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too foreach (var i in _tempIgnoredPaths.Keys) { diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 3db8265f6e..92a87d9f50 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -88,6 +88,7 @@ namespace Emby.Server.Implementations.Library private readonly ExtraResolver _extraResolver; private readonly IPathManager _pathManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly FastConcurrentLru<Guid, BaseItem> _cache; private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule; private readonly IMediaStreamRepository _mediaStreamRepository; @@ -189,6 +190,7 @@ namespace Emby.Server.Implementations.Library _pathManager = pathManager; _dotIgnoreIgnoreRule = dotIgnoreIgnoreRule; _localization = localization; + _directoryService = directoryService; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); _configurationManager.ConfigurationUpdated += ConfigurationUpdated; @@ -3720,6 +3722,10 @@ namespace Emby.Server.Implementations.Library AddMediaPathInternal(name, path, false); } } + + // The directory caches are shared, so the validation below would otherwise resolve + // the libraries root from a listing taken before this folder was created. + _directoryService.Invalidate(virtualFolderPath); } finally { @@ -3920,6 +3926,7 @@ namespace Emby.Server.Implementations.Library try { Directory.Delete(path, true); + _directoryService.Invalidate(path); } finally { @@ -3989,6 +3996,7 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(shortcut)) { _fileSystem.DeleteFile(shortcut); + _directoryService.Invalidate(shortcut); } var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath); @@ -4032,6 +4040,7 @@ namespace Emby.Server.Implementations.Library } _fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path)); + _directoryService.Invalidate(lnk); RemoveContentTypeOverrides(path); } diff --git a/Jellyfin.Api/Controllers/LibraryStructureController.cs b/Jellyfin.Api/Controllers/LibraryStructureController.cs index 5c596c21b9..5cd6dada19 100644 --- a/Jellyfin.Api/Controllers/LibraryStructureController.cs +++ b/Jellyfin.Api/Controllers/LibraryStructureController.cs @@ -16,6 +16,7 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Microsoft.AspNetCore.Authorization; @@ -34,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController private readonly IServerApplicationPaths _appPaths; private readonly ILibraryManager _libraryManager; private readonly ILibraryMonitor _libraryMonitor; + private readonly IDirectoryService _directoryService; /// <summary> /// Initializes a new instance of the <see cref="LibraryStructureController"/> class. @@ -41,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> /// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param> /// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param> + /// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param> public LibraryStructureController( IServerConfigurationManager serverConfigurationManager, ILibraryManager libraryManager, - ILibraryMonitor libraryMonitor) + ILibraryMonitor libraryMonitor, + IDirectoryService directoryService) { _appPaths = serverConfigurationManager.ApplicationPaths; _libraryManager = libraryManager; _libraryMonitor = libraryMonitor; + _directoryService = directoryService; } /// <summary> @@ -183,6 +188,11 @@ public class LibraryStructureController : BaseJellyfinApiController } Directory.Move(currentPath, newPath); + + // The directory caches are shared, so the validation below would otherwise resolve the + // libraries root from a listing taken before the folder was moved. + _directoryService.Invalidate(currentPath); + _directoryService.Invalidate(newPath); } finally { diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs index 43f0f11dfe..336e35e293 100644 --- a/MediaBrowser.Controller/Providers/DirectoryService.cs +++ b/MediaBrowser.Controller/Providers/DirectoryService.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Runtime.CompilerServices; using BitFaster.Caching.Lru; using MediaBrowser.Model.IO; @@ -11,33 +12,20 @@ namespace MediaBrowser.Controller.Providers { public class DirectoryService : IDirectoryService { - private const int DirectoryCacheSize = 2048; - private const int FileCacheSize = 4096; - - // A bounded LRU sizes its table up front, so it costs several kilobytes while still empty. - // One instance is a DI singleton and lives for the process, but the library code also news - // one up per item in several loops and hands it to QueueRefresh, which holds on to it until - // the refresh runs. Those instances usually ask about a single path, so each cache waits - // until something actually looks in it. - private readonly Lazy<FastConcurrentLru<string, FileSystemMetadata[]>> _cache - = new(static () => new(Environment.ProcessorCount, DirectoryCacheSize, StringComparer.Ordinal)); - - private readonly Lazy<FastConcurrentLru<string, FileSystemMetadata>> _fileCache - = new(static () => new(Environment.ProcessorCount, FileCacheSize, StringComparer.Ordinal)); - - private readonly Lazy<FastConcurrentLru<string, List<string>>> _filePathCache - = new(static () => new(Environment.ProcessorCount, DirectoryCacheSize, StringComparer.Ordinal)); + private static readonly ConditionalWeakTable<IFileSystem, DirectoryCache> _caches = []; private readonly IFileSystem _fileSystem; + private readonly DirectoryCache _cache; public DirectoryService(IFileSystem fileSystem) { _fileSystem = fileSystem; + _cache = _caches.GetValue(fileSystem, static _ => new DirectoryCache()); } public FileSystemMetadata[] GetFileSystemEntries(string path) { - return _cache.Value.GetOrAdd( + return _cache.Entries.GetOrAdd( path, static (p, fileSystem) => { @@ -99,7 +87,7 @@ namespace MediaBrowser.Controller.Providers public FileSystemMetadata? GetFileSystemEntry(string path) { - if (!_fileCache.Value.TryGet(path, out var result)) + if (!_cache.Files.TryGet(path, out var result)) { var file = _fileSystem.GetFileSystemInfo(path); @@ -108,7 +96,7 @@ namespace MediaBrowser.Controller.Providers if (file?.Exists ?? false) { result = file; - _fileCache.Value.AddOrUpdate(path, result); + _cache.Files.AddOrUpdate(path, result); } } @@ -122,10 +110,12 @@ namespace MediaBrowser.Controller.Providers { if (clearCache) { - _filePathCache.Value.TryRemove(path, out _); + // Only what is remembered about this directory. Invalidate() also drops the parent, + // which a write needs but which is needless churn on a shared cache here. + Forget(path); } - var filePaths = _filePathCache.Value.GetOrAdd( + return _cache.FilePaths.GetOrAdd( path, static (p, fileSystem) => { @@ -139,13 +129,52 @@ namespace MediaBrowser.Controller.Providers } }, _fileSystem); + } - return filePaths; + public void Invalidate(string path) + { + // Everything remembered about the path itself, and the listing of the directory holding + // it, since writing a file changes what its directory contains. The caches belong to the + // file system rather than to this instance, so this is felt by every reader of it. + Forget(path); + + var parent = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(parent)) + { + Forget(parent); + } } public bool IsAccessible(string path) { return _fileSystem.GetFileSystemEntryPaths(path).Any(); } + + private void Forget(string path) + { + _cache.Entries.TryRemove(path, out _); + _cache.Files.TryRemove(path, out _); + _cache.FilePaths.TryRemove(path, out _); + } + + private sealed class DirectoryCache + { + private const int DirectoryCacheSize = 2048; + private const int FileCacheSize = 8192; + + // A DirectoryService no longer bounds how long its answers are trusted by dying, so a + // lifetime does. This is a staleness bound, not a snapshot: a long refresh can outlive it + // and re-read a directory partway through. + private static readonly TimeSpan _entryLifetime = TimeSpan.FromMinutes(1); + + public ConcurrentTLru<string, FileSystemMetadata[]> Entries { get; } + = new(Environment.ProcessorCount, DirectoryCacheSize, StringComparer.Ordinal, _entryLifetime); + + public ConcurrentTLru<string, FileSystemMetadata> Files { get; } + = new(Environment.ProcessorCount, FileCacheSize, StringComparer.Ordinal, _entryLifetime); + + public ConcurrentTLru<string, List<string>> FilePaths { get; } + = new(Environment.ProcessorCount, DirectoryCacheSize, StringComparer.Ordinal, _entryLifetime); + } } } diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs index 8a3fa33da3..609d094254 100644 --- a/MediaBrowser.Controller/Providers/IDirectoryService.cs +++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs @@ -23,6 +23,12 @@ namespace MediaBrowser.Controller.Providers IReadOnlyList<string> GetFilePaths(string path, bool clearCache); + /// <summary> + /// Forgets what is cached about a path and about the directory containing it. + /// </summary> + /// <param name="path">The file or directory path that changed.</param> + void Invalidate(string path); + bool IsAccessible(string path); } } diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index af31e373ef..bff076b27b 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -32,6 +32,7 @@ public class LyricManager : ILyricManager private readonly IFileSystem _fileSystem; private readonly ILibraryMonitor _libraryMonitor; private readonly IMediaSourceManager _mediaSourceManager; + private readonly IDirectoryService _directoryService; private readonly ILyricProvider[] _lyricProviders; private readonly ILyricParser[] _lyricParsers; @@ -43,6 +44,7 @@ public class LyricManager : ILyricManager /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> /// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param> /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> + /// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param> /// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param> /// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param> public LyricManager( @@ -50,6 +52,7 @@ public class LyricManager : ILyricManager IFileSystem fileSystem, ILibraryMonitor libraryMonitor, IMediaSourceManager mediaSourceManager, + IDirectoryService directoryService, IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers) { @@ -57,6 +60,7 @@ public class LyricManager : ILyricManager _fileSystem = fileSystem; _libraryMonitor = libraryMonitor; _mediaSourceManager = mediaSourceManager; + _directoryService = directoryService; _lyricProviders = lyricProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -250,6 +254,10 @@ public class LyricManager : ILyricManager { _libraryMonitor.ReportFileSystemChangeComplete(path, false); } + + // The refresh below reads the containing folder to find external lyrics, and would find + // the deleted one again in a cached listing. + _directoryService.Invalidate(path); } return audio.RefreshMetadata(CancellationToken.None); @@ -446,6 +454,11 @@ public class LyricManager : ILyricManager await stream.CopyToAsync(fs).ConfigureAwait(false); } + // The directory caches are shared and outlive this call, so the refresh that follows + // would otherwise resolve external lyrics from a listing taken before this file + // landed. + _directoryService.Invalidate(savePath); + return; } catch (Exception ex) diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index c3458d4b2a..4a2f58d457 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles private readonly ILibraryMonitor _monitor; private readonly IMediaSourceManager _mediaSourceManager; private readonly ILocalizationManager _localization; + private readonly IDirectoryService _directoryService; private readonly HashSet<string> _allowedSubtitleFormats; private readonly ISubtitleProvider[] _subtitleProviders; @@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles ILibraryMonitor monitor, IMediaSourceManager mediaSourceManager, ILocalizationManager localizationManager, + IDirectoryService directoryService, IEnumerable<ISubtitleProvider> subtitleProviders, NamingOptions namingOptions) { @@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles _monitor = monitor; _mediaSourceManager = mediaSourceManager; _localization = localizationManager; + _directoryService = directoryService; _subtitleProviders = subtitleProviders .OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0) .ToArray(); @@ -281,6 +284,11 @@ namespace MediaBrowser.Providers.Subtitles await stream.CopyToAsync(fs).ConfigureAwait(false); } + // The directory caches are shared and outlive this call, so the refresh that + // follows would otherwise resolve external subtitles from a listing taken + // before this file landed. + _directoryService.Invalidate(path); + return; } else @@ -395,6 +403,10 @@ namespace MediaBrowser.Providers.Subtitles _monitor.ReportFileSystemChangeComplete(path, false); } + // The refresh below reads the containing folder to find external subtitles, and would + // find the deleted one again in a cached listing. + _directoryService.Invalidate(path); + return item.RefreshMetadata(CancellationToken.None); } diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs index bc03bfc33b..a550828783 100644 --- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs +++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs @@ -268,10 +268,10 @@ namespace Jellyfin.Controller.Tests [Fact] public void GetFileSystemEntries_FarMorePathsThanTheCacheHolds_EvictsInsteadOfGrowing() { - // The service is a singleton, so the cache has to give entries back rather than hold every - // path the server ever saw. Asking for far more paths than it can hold must push the first - // one out, which shows up as the file system being read for it a second time. - const int PathCount = 40000; + // The cache outlives every DirectoryService that reads it, so it has to give entries back + // rather than hold every path the server ever saw. Asking for more paths than it can hold + // must push the first one out, which shows up as the file system being read for it twice. + const int PathCount = 8192; var fileSystemMock = new Mock<IFileSystem>(); fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>())) @@ -279,17 +279,132 @@ namespace Jellyfin.Controller.Tests var directoryService = new DirectoryService(fileSystemMock.Object); - var firstPath = "/music/artist0"; - directoryService.GetFileSystemEntries(firstPath); + const string FirstPath = "/music/artist0"; + directoryService.GetFileSystemEntries(FirstPath); for (var i = 1; i < PathCount; i++) { directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture)); } - directoryService.GetFileSystemEntries(firstPath); + directoryService.GetFileSystemEntries(FirstPath); - fileSystemMock.Verify(f => f.GetFileSystemEntries(firstPath), Times.Exactly(2)); + fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2)); + } + + [Fact] + public void GetFileSystemEntries_SecondServiceOverSameFileSystem_ReusesTheFirstAnswer() + { + // The library code news up a DirectoryService per item, so what one of them learned about + // a directory has to be worth something to the next one reading the same file system. + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata); + + new DirectoryService(fileSystemMock.Object).GetFileSystemEntries(LowerCasePath); + var result = new DirectoryService(fileSystemMock.Object).GetFileSystemEntries(LowerCasePath); + + Assert.Equal(_lowerCaseFileSystemMetadata, result); + fileSystemMock.Verify(f => f.GetFileSystemEntries(LowerCasePath), Times.Once); + } + + [Fact] + public void GetFileSystemEntries_SeparateFileSystems_DoNotShareAnswers() + { + var firstFileSystem = new Mock<IFileSystem>(); + firstFileSystem.Setup(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata); + var secondFileSystem = new Mock<IFileSystem>(); + secondFileSystem.Setup(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_upperCaseFileSystemMetadata); + + var firstResult = new DirectoryService(firstFileSystem.Object).GetFileSystemEntries(LowerCasePath); + var secondResult = new DirectoryService(secondFileSystem.Object).GetFileSystemEntries(LowerCasePath); + + Assert.Equal(_lowerCaseFileSystemMetadata, firstResult); + Assert.Equal(_upperCaseFileSystemMetadata, secondResult); + } + + [Fact] + public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths() + { + // Clearing only one of the two views of a directory leaves the other one answering from + // before whatever was just written there. + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + fileSystemMock.SetupSequence(f => f.GetFilePaths(LowerCasePath, false)) + .Returns(new[] { LowerCasePath + "/Song 2.mp3" }) + .Returns(new[] { LowerCasePath + "/Song 2.mp3", LowerCasePath + "/Song 2.srt" }); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(LowerCasePath); + directoryService.GetFilePaths(LowerCasePath); + + directoryService.Invalidate(LowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(LowerCasePath)); + Assert.Equal(2, directoryService.GetFilePaths(LowerCasePath).Count); + } + + [Fact] + public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt() + { + // Downloading a subtitle changes what its folder contains, not just the one path. + const string NewFile = LowerCasePath + "/Song 2.srt"; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(LowerCasePath); + + directoryService.Invalidate(NewFile); + + Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(LowerCasePath)); + } + + [Fact] + public void Invalidate_OnOneService_IsSeenByAnotherOverTheSameFileSystem() + { + // Whoever writes the file and whoever refreshes the item hold different services, so + // invalidating has to reach the cache both of them read. + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(LowerCasePath)) + .Returns(_lowerCaseFileSystemMetadata) + .Returns(_upperCaseFileSystemMetadata); + + new DirectoryService(fileSystemMock.Object).GetFileSystemEntries(LowerCasePath); + new DirectoryService(fileSystemMock.Object).Invalidate(LowerCasePath + "/Song 2.srt"); + + var result = new DirectoryService(fileSystemMock.Object).GetFileSystemEntries(LowerCasePath); + + Assert.Equal(_upperCaseFileSystemMetadata, result); + } + + [Fact] + public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory() + { + // Re-reading one directory is not a reason to make the server list the library folder + // holding it again, which the shared cache would otherwise have to do. + const string ParentPath = "/music"; + + var fileSystemMock = new Mock<IFileSystem>(); + fileSystemMock.Setup(f => f.GetFilePaths(LowerCasePath)) + .Returns(new[] { LowerCasePath + "/Song 2.mp3" }); + fileSystemMock.Setup(f => f.GetFileSystemEntries(ParentPath)) + .Returns(_lowerCaseFileSystemMetadata); + + var directoryService = new DirectoryService(fileSystemMock.Object); + directoryService.GetFileSystemEntries(ParentPath); + + directoryService.GetFilePaths(LowerCasePath, true); + + directoryService.GetFileSystemEntries(ParentPath); + fileSystemMock.Verify(f => f.GetFileSystemEntries(ParentPath), Times.Once); } [Fact] |
