Merge pull request #17763 from Shadowghost/fix-refresh-queue-and-directory-cache

Fix items being lost from the refresh queue and bound the directory service caches
This commit is contained in:
Cody Robibero
2026-09-06 07:36:13 -04:00
committed by GitHub
14 changed files with 879 additions and 85 deletions
@@ -1,3 +1,5 @@
using System.Globalization;
using System.IO;
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests
{
public class DirectoryServiceTests
{
private const string LowerCasePath = "/music/someartist";
private const string UpperCasePath = "/music/SOMEARTIST";
// Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the
// separators, so cache keys only match the parent it returns when they use the platform's.
private static readonly string _lowerCasePath = LocalPath("/music/someartist");
private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST");
private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata =
{
new()
{
FullName = LowerCasePath + "/Artwork",
FullName = Path.Combine(_lowerCasePath, "Artwork"),
IsDirectory = true
},
new()
{
FullName = LowerCasePath + "/Some Other Folder",
FullName = Path.Combine(_lowerCasePath, "Some Other Folder"),
IsDirectory = true
},
new()
{
FullName = LowerCasePath + "/Song 2.mp3",
FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"),
IsDirectory = false
},
new()
{
FullName = LowerCasePath + "/Song 3.mp3",
FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"),
IsDirectory = false
}
};
@@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests
{
new()
{
FullName = UpperCasePath + "/Lyrics",
FullName = Path.Combine(_upperCasePath, "Lyrics"),
IsDirectory = true
},
new()
{
FullName = UpperCasePath + "/Song 1.mp3",
FullName = Path.Combine(_upperCasePath, "Song 1.mp3"),
IsDirectory = false
}
};
@@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests
public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath);
var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath);
var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath);
var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult);
@@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests
public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetFiles(UpperCasePath);
var lowerCaseResult = directoryService.GetFiles(LowerCasePath);
var upperCaseResult = directoryService.GetFiles(_upperCasePath);
var lowerCaseResult = directoryService.GetFiles(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult);
@@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests
public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetDirectories(UpperCasePath);
var lowerCaseResult = directoryService.GetDirectories(LowerCasePath);
var upperCaseResult = directoryService.GetDirectories(_upperCasePath);
var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult);
@@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests
Assert.Equal(cachedPaths, result);
Assert.Equal(newPaths, secondResult);
}
[Fact]
public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce()
{
var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict);
fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath))
.Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
directoryService.GetFileSystemEntries(_lowerCasePath);
directoryService.GetFileSystemEntries(_lowerCasePath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once);
}
[Fact]
public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
.Returns(_lowerCaseFileSystemMetadata)
.Returns(_upperCaseFileSystemMetadata);
fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false))
.Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") })
.Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_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()
{
var newFile = Path.Combine(_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 GetFilePaths_ClearingTheCache_KeepsTheParentDirectory()
{
var parentPath = LocalPath("/music");
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath))
.Returns(new[] { Path.Combine(_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]
public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache()
{
// Charged by the files in a listing, not the number of listings, so a few big folders
// reach the limit where a lot of small ones would not.
const int FolderCount = 60;
var bigListing = new FileSystemMetadata[5000];
for (var i = 0; i < bigListing.Length; i++)
{
bigListing[i] = new FileSystemMetadata
{
FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
IsDirectory = false
};
}
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
.Returns(bigListing);
var directoryService = new DirectoryService(fileSystemMock.Object);
const string FirstPath = "/music/artist0";
directoryService.GetFileSystemEntries(FirstPath);
for (var i = 1; i < FolderCount; i++)
{
directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture));
}
directoryService.GetFileSystemEntries(FirstPath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2));
}
[Fact]
public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached()
{
// Invalidating gives the records back, so churning one folder must not add up to the
// ceiling and drop everything else with it.
const int ChurnCount = 50;
var bigListing = new FileSystemMetadata[5000];
for (var i = 0; i < bigListing.Length; i++)
{
bigListing[i] = new FileSystemMetadata
{
FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
IsDirectory = false
};
}
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
.Returns(bigListing);
var directoryService = new DirectoryService(fileSystemMock.Object);
const string ChurnedPath = "/music/watched";
const string StablePath = "/music/untouched";
directoryService.GetFileSystemEntries(StablePath);
for (var i = 0; i < ChurnCount; i++)
{
directoryService.GetFileSystemEntries(ChurnedPath);
directoryService.Invalidate(ChurnedPath);
}
directoryService.GetFileSystemEntries(StablePath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once);
}
[Fact]
public void GetFileSystemEntry_MissingPath_IsNotRemembered()
{
const string MissingPath = "/music/not-here";
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath))
.Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false })
.Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true });
var directoryService = new DirectoryService(fileSystemMock.Object);
Assert.Null(directoryService.GetFileSystemEntry(MissingPath));
Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath));
}
private static string LocalPath(string path)
=> path.Replace('/', Path.DirectorySeparatorChar);
}
}
@@ -0,0 +1,216 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.LibraryTaskScheduler;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests.LibraryTaskScheduler
{
public class LimitedConcurrencyLibrarySchedulerTests
{
private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50);
// Generous, because these only ever wait for something that should already have happened.
private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task Enqueue_ProcessesEveryItem()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
var data = Enumerable.Range(0, 100).ToArray();
var processed = new ConcurrentBag<int>();
await scheduler.Enqueue(
data,
(item, _) =>
{
processed.Add(item);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(data, processed.Order());
}
}
[Fact]
public async Task Enqueue_WithFailingWorker_StillCompletes()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
await scheduler.Enqueue(
Enumerable.Range(0, 20).ToArray(),
(item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
}
}
/// <summary>
/// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>,
/// so a shutdown has to reach them. It does not travel from the linked source back to the one
/// the cleanup cancels, which is what made them immortal.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task ApplicationStopping_RetiresRunners()
{
using var appStopping = new CancellationTokenSource();
// Long enough that the cleanup cannot be what retires them.
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await using (scheduler)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0);
await appStopping.CancelAsync();
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
/// <summary>
/// The cleanup used to be a one shot: it never released the scheduling slot it took, so
/// every runner spawned after the first pass stayed around for the lifetime of the server.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
for (var round = 0; round < 3; round++)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}");
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
}
/// <summary>
/// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for
/// up to a minute.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await RunOneOperation(scheduler);
var stopwatch = Stopwatch.StartNew();
await scheduler.DisposeAsync();
Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}");
}
[Fact]
public async Task Enqueue_AfterDispose_DoesNothing()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await scheduler.DisposeAsync();
var processed = 0;
await scheduler.Enqueue(
Enumerable.Range(0, 10).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref processed);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(0, processed);
}
[Theory]
[InlineData(1)]
[InlineData(4)]
public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout)
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, fanout: fanout);
await using (scheduler)
{
var inner = 0;
var outer = scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => scheduler.Enqueue(
Enumerable.Range(0, 4).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref inner);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None),
new Progress<double>(),
CancellationToken.None);
await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken);
Assert.Equal(32, inner);
}
}
private static LimitedConcurrencyLibraryScheduler CreateScheduler(
CancellationTokenSource appStopping,
int fanout = 4,
TimeSpan? gracePeriod = null)
{
var lifetime = new Mock<IHostApplicationLifetime>();
lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token);
var configurationManager = new Mock<IServerConfigurationManager>();
configurationManager.SetupGet(x => x.Configuration)
.Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout });
return new LimitedConcurrencyLibraryScheduler(
lifetime.Object,
NullLogger<LimitedConcurrencyLibraryScheduler>.Instance,
configurationManager.Object,
gracePeriod ?? _shortGracePeriod);
}
private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler)
=> scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
private static async Task WaitForAsync(Func<bool> condition)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle");
await Task.Delay(20, TestContext.Current.CancellationToken);
}
}
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager
GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true);
}
[Fact]
public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne()
{
const int ItemCount = 2000;
var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
var processed = new ConcurrentBag<Guid>();
var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
.Returns((Guid id) =>
{
// Returning null drains the entry without the whole refresh machinery.
processed.Add(id);
if (processed.Count == ItemCount)
{
allProcessed.TrySetResult();
}
return null;
});
using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
await Parallel.ForEachAsync(
queued,
TestContext.Current.CancellationToken,
(id, _) =>
{
providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
return ValueTask.CompletedTask;
});
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await allProcessed.Task.WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Fall through so the assertions report what was lost.
}
Assert.Empty(providerManager.GetRefreshQueue());
Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray());
}
[Fact]
public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue()
{
// A provider timeout arrives as an OperationCanceledException, indistinguishable from
// a shutdown; treating it as one would strand the rest of the queue.
const int ItemCount = 200;
var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
var processed = new ConcurrentBag<Guid>();
var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var allQueued = new ManualResetEventSlim(false);
var cancelledOnce = false;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
.Returns((Guid id) =>
{
if (!cancelledOnce)
{
cancelledOnce = true;
// Hold the first entry until the whole batch is queued.
allQueued.Wait(TimeSpan.FromSeconds(30));
throw new OperationCanceledException("provider timed out");
}
processed.Add(id);
if (processed.Count == ItemCount - 1)
{
allProcessed.TrySetResult();
}
return null;
});
using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
foreach (var id in queued)
{
providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
}
allQueued.Set();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await allProcessed.Task.WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Fall through so the assertions report what was stranded.
}
Assert.Empty(providerManager.GetRefreshQueue());
Assert.Equal(ItemCount - 1, processed.Count);
}
private static void GetMetadataProviders_CanRefreshMetadata_Tester(
string providerType,
bool expected,
@@ -554,15 +665,20 @@ namespace Jellyfin.Providers.Tests.Manager
private static ProviderManager GetProviderManager(
ServerConfiguration? serverConfiguration = null,
LibraryOptions? libraryOptions = null,
IBaseItemManager? baseItemManager = null)
IBaseItemManager? baseItemManager = null,
ILibraryManager? libraryManager = null)
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict);
serverConfigurationManager.Setup(i => i.Configuration)
.Returns(serverConfiguration ?? new ServerConfiguration());
var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict);
libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
.Returns(libraryOptions ?? new LibraryOptions());
if (libraryManager is null)
{
var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict);
libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
.Returns(libraryOptions ?? new LibraryOptions());
libraryManager = libraryManagerMock.Object;
}
var providerManager = new ProviderManager(
Mock.Of<IHttpClientFactory>(),
@@ -572,7 +688,7 @@ namespace Jellyfin.Providers.Tests.Manager
_logger,
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
libraryManager.Object,
libraryManager,
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
@@ -6,8 +6,11 @@ using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Models.LibraryStructureDto;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
using Xunit.v3.Priority;
@@ -25,6 +28,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
_factory = factory;
}
[Fact]
[Priority(-3)]
public async Task AddVirtualFolder_WithWarmDirectoryServiceCache_InvalidatesTheParentListing()
{
const string Name = "stale-cache-test";
var client = _factory.CreateClient();
client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
var directoryService = _factory.Services.GetRequiredService<IDirectoryService>();
var rootFolderPath = _factory.Services.GetRequiredService<IServerApplicationPaths>().DefaultUserViewsPath;
// Cache a listing of the libraries root taken before the new folder exists. Everything
// resolving through this DirectoryService keeps reading that listing until it is dropped,
// so the library stays invisible. Making the caches shared once turned this into a real
// test failure, see UpdateLibraryOptions_Valid_Success.
Assert.DoesNotContain(
directoryService.GetFileSystemEntries(rootFolderPath),
x => string.Equals(x.Name, Name, StringComparison.Ordinal));
var body = new AddVirtualFolderDto()
{
LibraryOptions = new LibraryOptions()
{
Enabled = false
}
};
using var response = await client.PostAsJsonAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", body, _jsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
Assert.Contains(
directoryService.GetFileSystemEntries(rootFolderPath),
x => string.Equals(x.Name, Name, StringComparison.Ordinal));
using var cleanup = await client.DeleteAsync($"Library/VirtualFolders?name={Name}&refreshLibrary=false", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, cleanup.StatusCode);
}
[Fact]
[Priority(-1)]
public async Task Post_NewVirtualFolder_NotFound()