aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShadowghost <Ghost_of_Stone@web.de>2026-09-02 07:06:08 +0200
committerShadowghost <Ghost_of_Stone@web.de>2026-09-02 07:06:08 +0200
commitd73e3d964e3ce8197ec86bdaad447072305bc0d6 (patch)
treeac6e3add8f901f4b17fe5815fbfc30832235d955
parente5dc3b8a54fdad218ba062f8adb58fb5e42d8f9d (diff)
Optimize Caches
Co-Authored-By: Cody Robibero <cody@robibe.ro>
-rw-r--r--Emby.Server.Implementations/IO/LibraryMonitor.cs11
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs9
-rw-r--r--Jellyfin.Api/Controllers/LibraryStructureController.cs12
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs73
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs6
-rw-r--r--MediaBrowser.Providers/Lyric/LyricManager.cs13
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs12
-rw-r--r--tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs131
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]