aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs210
-rw-r--r--tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs78
-rw-r--r--tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs216
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs162
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs40
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj26
-rw-r--r--tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs99
-rw-r--r--tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs72
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs103
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs104
-rw-r--r--tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs129
-rw-r--r--tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs3
-rw-r--r--tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs126
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs140
-rw-r--r--tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs75
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs2
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs123
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs78
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs142
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs174
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs72
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs77
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs3
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs117
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs33
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs137
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs30
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs95
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs141
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs94
-rw-r--r--tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs112
-rw-r--r--tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs54
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs21
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo43
34 files changed, 3104 insertions, 27 deletions
diff --git a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
index 1f59908a86..e57fbfe473 100644
--- a/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
+++ b/tests/Jellyfin.Controller.Tests/DirectoryServiceTests.cs
@@ -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);
}
}
diff --git a/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
new file mode 100644
index 0000000000..b4ec2f1903
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/IO/FileSystemHelperTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.IO;
+using MediaBrowser.Controller.IO;
+using Xunit;
+
+namespace Jellyfin.Controller.Tests.IO;
+
+public class FileSystemHelperTests
+{
+ private static readonly string _parentPath = Path.Combine(Path.GetTempPath(), "jellyfin-test", "root", "default");
+
+ [Theory]
+ [InlineData("Movies")]
+ [InlineData("My Movies")]
+ [InlineData("..2")]
+ [InlineData("a.b")]
+ public void GetChildPath_ValidName_ReturnsPathInsideParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ Assert.Equal(Path.Combine(_parentPath, name), path);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(".")]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("../../etc")]
+ [InlineData("Movies/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ [InlineData("sub/folder")]
+ [InlineData("with\0null")]
+ public void GetChildPath_EscapingName_ReturnsNull(string name)
+ {
+ Assert.Null(FileSystemHelper.GetChildPath(_parentPath, name));
+ }
+
+ [Theory]
+ [InlineData("..\\..")]
+ [InlineData("sub\\folder")]
+ [InlineData("C:\\Windows")]
+ public void GetChildPath_WindowsSeparator_DoesNotEscapeParent(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ // On Windows these are rejected outright, on other platforms a backslash is a legal file name character.
+ Assert.True(path is null || string.Equals(Path.GetDirectoryName(path), _parentPath, StringComparison.Ordinal));
+ }
+
+ [Theory]
+ [InlineData("...")]
+ [InlineData("Movies.")]
+ [InlineData("Movies ")]
+ public void GetChildPath_TrailingDotOrSpace_RejectedOnWindows(string name)
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath, name);
+
+ if (OperatingSystem.IsWindows())
+ {
+ // Windows trims trailing dots and spaces, so the name would resolve to the parent or to a different child.
+ Assert.Null(path);
+ }
+ else
+ {
+ Assert.Equal(Path.Combine(_parentPath, name), path);
+ }
+ }
+
+ [Fact]
+ public void GetChildPath_ParentWithTrailingSeparator_ReturnsPathInsideParent()
+ {
+ var path = FileSystemHelper.GetChildPath(_parentPath + Path.DirectorySeparatorChar, "Movies");
+
+ Assert.Equal(Path.Combine(_parentPath, "Movies"), path);
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
new file mode 100644
index 0000000000..686d839f4f
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/LibraryTaskScheduler/LimitedConcurrencyLibrarySchedulerTests.cs
@@ -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);
+ }
+ }
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
new file mode 100644
index 0000000000..557035e2d1
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperDoviTests.cs
@@ -0,0 +1,162 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Controller.Streaming;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using Moq;
+using Xunit;
+
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperDoviTests
+{
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("unknown", false)]
+ [InlineData("bt2020-10", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("arib-std-b67", true)]
+ public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap)
+ {
+ var state = CreateState("hevc", transfer);
+ var helper = CreateHelper(true);
+
+ var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264");
+ var args = string.Join(',', filters);
+
+ Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType);
+ Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal));
+ Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal);
+ }
+
+ [Theory]
+ [InlineData(null, false)]
+ [InlineData("bt709", false)]
+ [InlineData("arib-std-b67", false)]
+ [InlineData("smpte2084", true)]
+ [InlineData("SMPTE2084", true)]
+ public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected)
+ {
+ var stream = CreateState("hevc", transfer).VideoStream;
+
+ Assert.True(EncodingHelper.IsDovi(stream));
+ Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream));
+ }
+
+ [Theory]
+ [InlineData("hevc", null, "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")]
+ [InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")]
+ [InlineData("av1", null, "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")]
+ [InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")]
+ public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected)
+ {
+ var state = CreateState(codec, transfer);
+ var helper = CreateHelper(true);
+
+ foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) })
+ {
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.Equal(removeDovi, helper.IsDoviRemoved(state));
+ if (removeDovi)
+ {
+ Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal);
+ }
+ else
+ {
+ Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ Assert.False(CreateHelper(false).IsDoviRemoved(state));
+ }
+ }
+
+ [Theory]
+ [InlineData(null, true)]
+ [InlineData("HDR10", true)]
+ [InlineData("DOVI", false)]
+ [InlineData("SDR,DOVI", false)]
+ public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport)
+ {
+ foreach (var codec in new[] { "hevc", "av1" })
+ {
+ foreach (var transfer in new[] { "bt709", "smpte2084" })
+ {
+ var state = CreateState(codec, transfer);
+ state.BaseRequest.VideoRangeType = requestedRanges;
+
+ Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream));
+ Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream));
+ }
+ }
+ }
+
+ [Fact]
+ public void GetBitStreamArgs_ValidDovi_PreservesMetadata()
+ {
+ var state = CreateState("hevc", "smpte2084");
+ state.VideoStream.ColorSpace = "bt2020nc";
+ state.VideoStream.ColorPrimaries = "bt2020";
+ state.BaseRequest.VideoRangeType = "DOVIWithEL";
+ var helper = CreateHelper(true);
+
+ Assert.False(helper.IsDoviRemoved(state));
+ Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video));
+ }
+
+ private static EncodingJobInfo CreateState(string codec, string? transfer)
+ {
+ var stream = new MediaStream
+ {
+ Type = MediaStreamType.Video,
+ Codec = codec,
+ Width = 1920,
+ Height = 1080,
+ BitDepth = 10,
+ DvProfile = codec == "hevc" ? 7 : 10,
+ DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ColorSpace = "bt709",
+ ColorPrimaries = "bt709",
+ ColorTransfer = transfer
+ };
+
+ return new EncodingJobInfo(TranscodingJobType.Hls)
+ {
+ VideoStream = stream,
+ MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] },
+ BaseRequest = new VideoRequestDto(),
+ OutputVideoCodec = "copy",
+ IsVideoRequest = true,
+ IsInputVideo = true
+ };
+ }
+
+ private static EncodingHelper CreateHelper(bool supportsRemoval)
+ {
+ var encoder = new Mock<IMediaEncoder>();
+ encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval);
+ encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true);
+ encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1));
+
+ return new EncodingHelper(
+ Mock.Of<IApplicationPaths>(),
+ encoder.Object,
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+ }
+}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
new file mode 100644
index 0000000000..586db2dd50
--- /dev/null
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperInferAudioCodecTests.cs
@@ -0,0 +1,40 @@
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.IO;
+using MediaBrowser.Controller.MediaEncoding;
+using Moq;
+using Xunit;
+using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
+
+namespace Jellyfin.Controller.Tests.MediaEncoding;
+
+public class EncodingHelperInferAudioCodecTests
+{
+ [Theory]
+ // Manifests and other containers that carry no inferable audio codec.
+ [InlineData("m3u8", "aac")]
+ [InlineData("mpd", "aac")]
+ [InlineData("wtv", "aac")]
+ [InlineData("", "aac")]
+ // Containers with a well known audio codec.
+ [InlineData("mp4", "aac")]
+ [InlineData("mkv", "aac")]
+ [InlineData("webm", "opus")]
+ [InlineData("ts", "mp3")]
+ // Containers named after the codec they carry.
+ [InlineData("flac", "flac")]
+ [InlineData("opus", "opus")]
+ [InlineData("ac3", "ac3")]
+ public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
+ {
+ Assert.Equal(expected, Create().InferAudioCodec(container));
+ }
+
+ private static EncodingHelper Create()
+ => new(
+ Mock.Of<IApplicationPaths>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<ISubtitleEncoder>(),
+ Mock.Of<IConfiguration>(),
+ Mock.Of<IConfigurationManager>(),
+ Mock.Of<IPathManager>());
+}
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
new file mode 100644
index 0000000000..b6dc5dfb92
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/Jellyfin.Drawing.Skia.Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <!-- ProjectGuid is only included as a requirement for SonarQube analysis -->
+ <PropertyGroup>
+ <ProjectGuid>{E24A279C-9A37-419A-8F9C-853C11FBE753}</ProjectGuid>
+ <OutputType>Exe</OutputType>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" />
+ <PackageReference Include="xunit.v3" />
+ <PackageReference Include="xunit.runner.visualstudio">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ <PackageReference Include="coverlet.collector">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="../../src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
new file mode 100644
index 0000000000..30b7983ece
--- /dev/null
+++ b/tests/Jellyfin.Drawing.Skia.Tests/SvgSecurityValidatorTests.cs
@@ -0,0 +1,99 @@
+using System.IO;
+using Xunit;
+
+namespace Jellyfin.Drawing.Skia.Tests;
+
+public static class SvgSecurityValidatorTests
+{
+ public static TheoryData<string> ExternalReferenceSvgs => new()
+ {
+ // SSRF via <image> (xlink:href and plain href)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='http://169.254.169.254/latest/meta-data/' width='16' height='16'/></svg>",
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><image href='https://example.invalid/a.png' width='16' height='16'/></svg>",
+ // Local file disclosure
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///etc/passwd' width='16' height='16'/></svg>",
+ // Memory exhaustion DoS
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='file:///dev/urandom' width='16' height='16'/></svg>",
+ // <use> external reference
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><use xlink:href='http://example.invalid/c.svg#a'/></svg>",
+ // CSS url() external reference in an attribute
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' style=\"fill:url(http://example.invalid/d.svg#g)\"/></svg>",
+ // @import in a style block
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><style>@import 'http://example.invalid/e.css';</style><rect width='16' height='16'/></svg>",
+ // Relative path traversal (resolves against the document location -> local file read)
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='../../../../etc/hosts' width='16' height='16'/></svg>",
+ // XXE via external entity
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY xxe SYSTEM 'file:///etc/passwd'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&xxe;</text></svg>",
+ // Entity-expansion (billion laughs) denial of service
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY a 'aaaaaaaaaa'><!ENTITY b '&a;&a;&a;&a;&a;&a;&a;&a;&a;&a;'><!ENTITY c '&b;&b;&b;&b;&b;&b;&b;&b;&b;&b;'><!ENTITY d '&c;&c;&c;&c;&c;&c;&c;&c;&c;&c;'><!ENTITY e '&d;&d;&d;&d;&d;&d;&d;&d;&d;&d;'><!ENTITY f '&e;&e;&e;&e;&e;&e;&e;&e;&e;&e;'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><text>&f;</text></svg>",
+ // Nested SVG in a base64 data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHhtbG5zOnhsaW5rPSdodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jz48aW1hZ2UgeGxpbms6aHJlZj0naHR0cDovL2V4YW1wbGUuaW52YWxpZC9uZXN0ZWQucG5nJyB3aWR0aD0nOCcgaGVpZ2h0PSc4Jy8+PC9zdmc+' width='16' height='16'/></svg>",
+ // Nested SVG in a URL-encoded (non-base64) data: URI referencing an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20xmlns%3Axlink%3D%27http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%27%3E%3Cimage%20xlink%3Ahref%3D%27file%3A%2F%2F%2Fetc%2Fpasswd%27%2F%3E%3C%2Fsvg%3E' width='16' height='16'/></svg>",
+ // Nested gzip-compressed (svgz) data: URI whose inner document references an external resource
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/23OwQrDIBAE0F/x5s217aWK8V+E2N2laiWRKP36Nin0lNvAPIZx64Zi5FTWSVJr1QL03lW/qdeCcNVaw1fIH7EjcXmewYsxBo5Wis5zo0nepaDISG2P3nEOGMVBLC3x8V+JI+SaouKyhcQz4FvVgucz4N1+x38AdK4P3LYAAAA=' width='16' height='16'/></svg>",
+ };
+
+ public static TheoryData<string> SafeSvgs => new()
+ {
+ "<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='red'/></svg>",
+ // Same-document fragment references are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><defs><linearGradient id='g'/></defs><rect width='16' height='16' fill='url(#g)'/><use xlink:href='#g'/></svg>",
+ // Inline data URIs are allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' width='16' height='16'/></svg>",
+ // A DOCTYPE without external entities is allowed
+ "<?xml version='1.0'?><!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16'/></svg>",
+ // An internal general entity with no external reference is allowed (and is expanded by the renderer)
+ "<?xml version='1.0'?><!DOCTYPE svg [<!ENTITY col 'red'>]><svg xmlns='http://www.w3.org/2000/svg' width='16' height='16'><rect width='16' height='16' fill='&col;'/></svg>",
+ // A nested data:image/svg+xml payload that is itself self-contained is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,PHN2ZyB4bWxucz0naHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmcnIHdpZHRoPSc4JyBoZWlnaHQ9JzgnPjxyZWN0IHdpZHRoPSc4JyBoZWlnaHQ9JzgnIGZpbGw9J2JsdWUnLz48L3N2Zz4=' width='16' height='16'/></svg>",
+ // A self-contained gzip-compressed (svgz) data: URI is allowed
+ "<svg xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='16' height='16'><image xlink:href='data:image/svg+xml;base64,H4sIAAAAAAAC/22Muw6AIAwAf6VbN0p0MQb4GBWBBB+Bav18ZXe75C5n6h3g2fJeLUbmcyQSESW9OkqgTmtNX4EgaeFocUCIPoXIDZ0pfuZfBWvK2eKUL4/kTHu4F2NB6oFrAAAA' width='16' height='16'/></svg>",
+ };
+
+ [Theory]
+ [MemberData(nameof(ExternalReferenceSvgs))]
+ public static void IsSafe_ExternalReference_ReturnsFalse(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.NotNull(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(SafeSvgs))]
+ public static void IsSafe_NoExternalReference_ReturnsTrue(string svg)
+ {
+ var path = WriteTemp(svg);
+ try
+ {
+ Assert.True(SvgSecurityValidator.IsSafe(path, out var reason));
+ Assert.Null(reason);
+ }
+ finally
+ {
+ File.Delete(path);
+ }
+ }
+
+ [Fact]
+ public static void IsSafe_MissingFile_ReturnsFalse()
+ {
+ Assert.False(SvgSecurityValidator.IsSafe(Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName() + ".svg"), out var reason));
+ Assert.NotNull(reason);
+ }
+
+ private static string WriteTemp(string svg)
+ {
+ var path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".svg");
+ File.WriteAllText(path, svg);
+ return path;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
new file mode 100644
index 0000000000..4487a5ff2b
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/M3UTunerHostTests.cs
@@ -0,0 +1,72 @@
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.TunerHosts;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.LiveTv;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests
+{
+ public class M3UTunerHostTests
+ {
+ [Theory]
+ // A manifest is not a byte stream, so it must never be offered for direct play.
+ [InlineData("http://example.com/live/1234.m3u8", false)]
+ [InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
+ [InlineData("http://example.com/live/1234.mpd", false)]
+ // Byte streams are unaffected.
+ [InlineData("http://example.com/live/1234.ts", true)]
+ [InlineData("http://example.com/live/1234", true)]
+ public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
+
+ var host = new TestableM3UTunerHost(
+ Mock.Of<IServerConfigurationManager>(),
+ mediaSourceManager.Object,
+ Mock.Of<ILogger<M3UTunerHost>>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IHttpClientFactory>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IStreamHelper>());
+
+ var sources = await host.GetMediaSources(
+ new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
+ new ChannelInfo { Path = path });
+
+ Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
+ }
+
+ private sealed class TestableM3UTunerHost : M3UTunerHost
+ {
+ public TestableM3UTunerHost(
+ IServerConfigurationManager config,
+ IMediaSourceManager mediaSourceManager,
+ ILogger<M3UTunerHost> logger,
+ IFileSystem fileSystem,
+ IHttpClientFactory httpClientFactory,
+ IServerApplicationHost appHost,
+ INetworkManager networkManager,
+ IStreamHelper streamHelper)
+ : base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
+ {
+ }
+
+ public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
+ => GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
+ }
+ }
+}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
new file mode 100644
index 0000000000..141164815c
--- /dev/null
+++ b/tests/Jellyfin.MediaEncoding.Tests/Encoder/ProcessWrapperTests.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.MediaEncoding.Encoder;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.MediaEncoding.Tests.Encoder;
+
+public class ProcessWrapperTests
+{
+ [Fact]
+ public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt()
+ {
+ using var process = CreateProcess();
+ using var exitHandled = new ManualResetEventSlim(false);
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ // Subscribed after the wrapper, so by the time this is set the wrapper's own handler has
+ // already run: whatever it does to the process has happened.
+ process.Exited += (_, _) => exitHandled.Set();
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited.");
+
+ // The caller still owns the process here. Disposing it from the exit handler handed
+ // whoever exited quickest an ObjectDisposedException out of these three lines.
+ var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+ Assert.Equal("jellyfin", output.Trim());
+
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitState_IsReadableBeforeTheExitEventArrives()
+ {
+ using var process = CreateProcess();
+
+ using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
+ {
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ // The exit event is raised on the thread pool and can lag behind the wait that just
+ // returned, so neither of these may depend on it having arrived.
+ Assert.True(wrapper.HasExited);
+ Assert.Equal(3, wrapper.ExitCode);
+ }
+ }
+
+ [Fact]
+ public async Task ExitCode_SurvivesDisposal()
+ {
+ using var process = CreateProcess();
+ var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder());
+
+ process.Start();
+ await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
+
+ var exitCode = wrapper.ExitCode;
+ wrapper.Dispose();
+
+ Assert.Equal(exitCode, wrapper.ExitCode);
+ Assert.True(wrapper.HasExited);
+ }
+
+ private static MediaEncoder CreateEncoder()
+ => new(
+ Mock.Of<ILogger<MediaEncoder>>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IBlurayExaminer>(),
+ Mock.Of<ILocalizationManager>(),
+ new ConfigurationBuilder().Build(),
+ Mock.Of<IServerConfigurationManager>());
+
+ // Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that
+ // rejects a file outright - the process that used to win the race against its own caller.
+ private static Process CreateProcess()
+ {
+ var startInfo = OperatingSystem.IsWindows()
+ ? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3")
+ : new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\"");
+
+ startInfo.CreateNoWindow = true;
+ startInfo.UseShellExecute = false;
+ startInfo.RedirectStandardOutput = true;
+
+ return new Process { StartInfo = startInfo, EnableRaisingEvents = true };
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
new file mode 100644
index 0000000000..dfd1eb2e85
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderManifestContainerTests.cs
@@ -0,0 +1,104 @@
+using System;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Dlna;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Session;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Dlna;
+
+public class StreamBuilderManifestContainerTests
+{
+ [Theory]
+ // A manifest describes a stream instead of carrying one, so it can never be direct played,
+ // even when the client claims to support the container.
+ [InlineData("hls")]
+ [InlineData("hls,applehttp")]
+ [InlineData("applehttp")]
+ [InlineData("dash")]
+ public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
+ {
+ var streamInfo = BuildFor(container);
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
+ }
+
+ [Fact]
+ public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
+ {
+ var streamInfo = BuildFor("mp4");
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
+ }
+
+ private static StreamInfo? BuildFor(string container)
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "test-source",
+ Path = "http://example.com/live/channel",
+ Protocol = MediaProtocol.Http,
+ Container = container,
+ SupportsDirectPlay = true,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true,
+ IsInfiniteStream = true,
+ IsRemote = true,
+ MediaStreams =
+ [
+ new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
+ new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
+ ]
+ };
+
+ var profile = new DeviceProfile
+ {
+ Name = "Manifest aware client",
+ DirectPlayProfiles =
+ [
+ new DirectPlayProfile
+ {
+ Type = DlnaProfileType.Video,
+ Container = "mp4,hls,applehttp,dash",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ],
+ TranscodingProfiles =
+ [
+ new TranscodingProfile
+ {
+ Type = DlnaProfileType.Video,
+ Context = EncodingContext.Streaming,
+ Protocol = MediaStreamProtocol.hls,
+ Container = "ts",
+ VideoCodec = "h264",
+ AudioCodec = "aac"
+ }
+ ]
+ };
+
+ var options = new MediaOptions
+ {
+ ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
+ MediaSourceId = mediaSource.Id,
+ MediaSources = [mediaSource],
+ DeviceId = "test-deviceId",
+ Profile = profile,
+ AllowAudioStreamCopy = true,
+ AllowVideoStreamCopy = true,
+ EnableDirectStream = false // This is disabled in server
+ };
+
+ var transcodeSupport = new Mock<ITranscoderSupport>();
+
+ return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
+ .GetOptimalVideoStream(options);
+ }
+}
diff --git a/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
new file mode 100644
index 0000000000..f264e5a019
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Entities/MediaStreamVideoRangeTests.cs
@@ -0,0 +1,129 @@
+using Jellyfin.Data.Enums;
+using MediaBrowser.Model.Entities;
+using Xunit;
+
+namespace Jellyfin.Model.Tests.Entities;
+
+public class MediaStreamVideoRangeTests
+{
+ [Theory]
+ [InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)]
+ [InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)]
+ [InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
+ [InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
+ [InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)]
+ [InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)]
+ public void GetVideoColorRange_ValidDovi_PreservesRangeType(
+ int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus);
+
+ Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", "bt709", "bt709", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)]
+ [InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)]
+ [InlineData(null, null, null, VideoRange.SDR)]
+ [InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)]
+ [InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)]
+ [InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)]
+ [InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)]
+ [InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange(
+ string? space, string? transfer, string? primaries, VideoRange expected)
+ {
+ // Cover every HDR-compatible DV profile, including the HDR10+ variants.
+ foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) })
+ {
+ foreach (var hdr10Plus in new[] { false, true })
+ {
+ var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus);
+
+ Assert.Equal(expected, stream.VideoRange);
+ Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType);
+ }
+ }
+ }
+
+ [Theory]
+ [InlineData(7, 6, "arib-std-b67")]
+ [InlineData(8, 1, "arib-std-b67")]
+ [InlineData(8, 4, "smpte2084")]
+ [InlineData(10, 1, "arib-std-b67")]
+ [InlineData(10, 4, "smpte2084")]
+ public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true);
+
+ Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
+ [InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ [InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
+ public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification(
+ int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false);
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData(8, null, VideoRange.SDR)]
+ [InlineData(8, "bt709", VideoRange.SDR)]
+ [InlineData(8, "smpte2084", VideoRange.HDR)]
+ [InlineData(10, null, VideoRange.SDR)]
+ [InlineData(10, "arib-std-b67", VideoRange.HDR)]
+ public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected)
+ {
+ var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false);
+
+ Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
+ }
+
+ [Theory]
+ [InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)]
+ [InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)]
+ [InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)]
+ [InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)]
+ public void GetVideoColorRange_WithoutDovi_PreservesClassification(
+ string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType)
+ {
+ var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus };
+
+ Assert.Equal((range, rangeType), stream.GetVideoColorRange());
+ stream.Type = MediaStreamType.Audio;
+ Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange());
+ }
+
+ private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus)
+ => new()
+ {
+ Type = MediaStreamType.Video,
+ DvProfile = profile,
+ DvBlSignalCompatibilityId = compatibilityId,
+ RpuPresentFlag = 1,
+ BlPresentFlag = 1,
+ ElPresentFlag = profile == 7 ? 1 : 0,
+ ColorSpace = space,
+ ColorTransfer = transfer,
+ ColorPrimaries = primaries,
+ Hdr10PlusPresentFlag = hdr10Plus
+ };
+}
diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
index 7e708c681d..4236749423 100644
--- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
+++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs
@@ -74,6 +74,9 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)]
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)]
[InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)]
+ // Episode markers in the episode title must not be read as an episode range
+ [InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)]
+ [InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)]
public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber)
{
var result = _episodePathParser.Parse(filename, false);
diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
index 5749944fcd..248b236df8 100644
--- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
+++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs
@@ -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>(),
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 876f18741f..ce451861ef 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -179,6 +179,146 @@ public class MediaInfoResolverTests
Assert.Empty(streams);
}
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile()
+ {
+ // VobSub (.sub) payloads only carry per-track language metadata when read
+ // alongside their paired .idx index file. When both are present, only the
+ // .idx file should be returned so it (not the raw .sub) gets probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile()
+ {
+ // An .idx file with no paired .sub cannot be probed for anything, so it must be
+ // left out entirely rather than surfaced as a doomed-to-fail probe candidate.
+ // Surfacing it anyway would also make it "exist" from Jellyfin's perspective
+ // even after the real .sub is deleted, preventing stale subtitle stream data
+ // from ever being cleared on a rescan.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ Assert.Empty(streams);
+ }
+
+ [Fact]
+ public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile()
+ {
+ // Guards against the .idx/.sub pairing suppression firing when there is no
+ // .idx sidecar at all - a lone .sub file must still be returned.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub");
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair()
+ {
+ // A same-named .idx and .sub split across the video folder and the internal
+ // metadata folder cannot be paired by ffprobe (it only looks next to the .idx),
+ // so the .sub must still be returned, but the orphaned .idx (no sibling .sub in
+ // its own directory) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" });
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub()
+ {
+ // A .idx/.sub pair sharing the same filename flags (e.g. a language token) should
+ // still pair and suppress the .sub, just like an unflagged pair.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair()
+ {
+ // An .idx and .sub with different basenames (e.g. differing filename flags) are not
+ // a pair ffprobe would resolve. The .sub must still be returned, but the orphaned
+ // .idx (no same-named sibling .sub) must be left out since it cannot be probed.
+ BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
+
+ var video = new Movie
+ {
+ Path = VideoDirectoryPath + "/My.Video.mkv"
+ };
+
+ var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
+ .Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" });
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
+ .Returns(Array.Empty<string>());
+
+ var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
+
+ var stream = Assert.Single(streams);
+ Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
+ }
+
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
diff --git a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
index 8f5b1b3c48..ea762256db 100644
--- a/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
+++ b/tests/Jellyfin.Providers.Tests/TV/EpisodeMetadataServiceTests.cs
@@ -1,5 +1,6 @@
using System;
using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -15,9 +16,23 @@ using Xunit;
namespace Jellyfin.Providers.Tests.TV;
-public class EpisodeMetadataServiceTests
+// put tests that mock the static LibraryManager in the same collection to avoid test interference
+[Collection("LibraryManagerTests")]
+public sealed class EpisodeMetadataServiceTests : IDisposable
{
private readonly TestEpisodeMetadataService _service = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public EpisodeMetadataServiceTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
[Fact]
public void MergeData_ProviderSeasonOverridesPathDerivedSeason()
@@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests
Assert.Equal(1, target.Item.ParentIndexNumber);
}
+ [Theory]
+ [InlineData(2, 1)]
+ [InlineData(22, 21)]
+ [InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title
+ public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ // The episode number identifies the item, so it is kept and the impossible range is dropped
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Null(item.IndexNumberEnd);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Fact]
+ public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd()
+ {
+ var item = new Episode
+ {
+ IndexNumber = null,
+ IndexNumberEnd = 2
+ };
+
+ var updateType = _service.BeforeSave(item);
+
+ Assert.Null(item.IndexNumberEnd);
+ Assert.Null(item.IndexNumber);
+ Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
+ }
+
+ [Theory]
+ [InlineData(1, 2)] // Regular multi episode file
+ [InlineData(1, 1)] // Degenerate but not contradictory
+ public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd)
+ {
+ var item = new Episode
+ {
+ IndexNumber = indexNumber,
+ IndexNumberEnd = indexNumberEnd
+ };
+
+ _service.BeforeSave(item);
+
+ Assert.Equal(indexNumber, item.IndexNumber);
+ Assert.Equal(indexNumberEnd, item.IndexNumberEnd);
+ }
+
private sealed class TestEpisodeMetadataService : EpisodeMetadataService
{
public TestEpisodeMetadataService()
@@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests
{
MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings);
}
+
+ public ItemUpdateType BeforeSave(Episode item)
+ {
+ return BeforeSaveInternal(item, false, ItemUpdateType.None);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
index bdac59c013..679e6d17e3 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs
@@ -154,7 +154,7 @@ public class DtoServiceTests
.Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny<IReadOnlyList<Guid>>(), user))
.Returns(new Dictionary<Guid, (int Played, int Total)> { [season.Id] = (playedCount, totalCount) });
_libraryManagerMock
- .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<Guid?>()))
+ .Setup(x => x.GetChildCountBatch(It.IsAny<IReadOnlyList<Guid>>(), It.IsAny<User?>()))
.Returns(new Dictionary<Guid, int> { [season.Id] = childCount });
return (season, user);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
new file mode 100644
index 0000000000..cdb261de8d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/LibraryChangedNotifierTests.cs
@@ -0,0 +1,123 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class LibraryChangedNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<ILibraryManager> _libraryManager = new();
+ private readonly Mock<IServerConfigurationManager> _configurationManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+ private readonly Mock<IProviderManager> _providerManager = new();
+ private readonly ServerConfiguration _configuration = new();
+
+ private int _flushCount;
+
+ public LibraryChangedNotifierTests()
+ {
+ _configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
+
+ // Reading the session list is the first thing a flush does, so it stands in for "a batch was
+ // sent" without having to mock a whole user library behind it.
+ _sessionManager.SetupGet(e => e.Sessions)
+ .Returns(() =>
+ {
+ Interlocked.Increment(ref _flushCount);
+ return [];
+ });
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
+ {
+ // Long enough that only the size cap can close the batch.
+ _configuration.LibraryUpdateDuration = 3600;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
+ {
+ RaiseItemUpdated();
+ }
+
+ Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ [Fact]
+ public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes items continuously. The window must run from the first change of a batch, or
+ // the batch never closes and holds every item it named alive for the length of the scan.
+ _configuration.LibraryUpdateDuration = 1;
+
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseItemUpdated();
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private LibraryChangedNotifier CreateNotifier()
+ => new(
+ _libraryManager.Object,
+ _configurationManager.Object,
+ _sessionManager.Object,
+ _userManager.Object,
+ NullLogger<LibraryChangedNotifier>.Instance,
+ _providerManager.Object);
+
+ // A folder passes the notifier's item filter without needing any of BaseItem's static services.
+ private void RaiseItemUpdated()
+ => _libraryManager.Raise(
+ e => e.ItemUpdated += null,
+ _libraryManager.Object,
+ new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
+
+ private async Task<bool> WaitForFlushAsync(int expected)
+ {
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout)
+ {
+ if (Volatile.Read(ref _flushCount) >= expected)
+ {
+ return true;
+ }
+
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ return false;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
new file mode 100644
index 0000000000..0274398f89
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/EntryPoints/UserDataChangeNotifierTests.cs
@@ -0,0 +1,78 @@
+using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
+using Emby.Server.Implementations.EntryPoints;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Session;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
+
+public class UserDataChangeNotifierTests
+{
+ // How long a test waits for the notifier's timer callback to run. Generous: the assertions are
+ // about a batch being sent at all, not about how promptly.
+ private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
+
+ private readonly Mock<IUserDataManager> _userDataManager = new();
+ private readonly Mock<ISessionManager> _sessionManager = new();
+ private readonly Mock<IUserManager> _userManager = new();
+
+ private int _flushCount;
+
+ public UserDataChangeNotifierTests()
+ {
+ _sessionManager
+ .Setup(e => e.SendMessageToUserSessions(
+ It.IsAny<System.Collections.Generic.List<Guid>>(),
+ SessionMessageType.UserDataChanged,
+ It.IsAny<Func<UserDataChangeInfo>>(),
+ It.IsAny<CancellationToken>()))
+ .Callback(() => Interlocked.Increment(ref _flushCount))
+ .Returns(Task.CompletedTask);
+ }
+
+ [Fact]
+ public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
+ {
+ // A scan changes user data continuously. The window must run from the first change of a batch,
+ // or the batch never closes and holds every item it named alive for the length of the scan.
+ var notifier = CreateNotifier();
+ await notifier.StartAsync(TestContext.Current.CancellationToken);
+
+ var userId = Guid.NewGuid();
+ var stopwatch = Stopwatch.StartNew();
+ while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
+ {
+ // Well below the window, and well below the size cap over the whole loop.
+ RaiseUserDataSaved(userId);
+ await Task.Delay(25, TestContext.Current.CancellationToken);
+ }
+
+ Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
+
+ await notifier.StopAsync(TestContext.Current.CancellationToken);
+ notifier.Dispose();
+ }
+
+ private UserDataChangeNotifier CreateNotifier()
+ => new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
+
+ // A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
+ // notifier ignores outright.
+ private void RaiseUserDataSaved(Guid userId)
+ => _userDataManager.Raise(
+ e => e.UserDataSaved += null,
+ _userDataManager.Object,
+ new UserDataSaveEventArgs
+ {
+ UserId = userId,
+ SaveReason = UserDataSaveReason.UpdateUserRating,
+ Item = new Folder { Id = Guid.NewGuid() }
+ });
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
new file mode 100644
index 0000000000..0ca11eb58d
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryDescendantFilterTests.cs
@@ -0,0 +1,142 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Data.Enums;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// Covers <see cref="InternalItemsQuery.DescendantOfId"/>, the filter a recursive query rooted at a
+/// BoxSet or Playlist runs on. Those hold their contents as linked children, so the items below a
+/// linked folder are only reachable by following the link and then the ancestor chain.
+/// </summary>
+public sealed class BaseItemRepositoryDescendantFilterTests : SqliteDbTestFixture
+{
+ private const string FolderType = "MediaBrowser.Controller.Entities.Folder";
+ private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet";
+ private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series";
+ private const string SeasonType = "MediaBrowser.Controller.Entities.TV.Season";
+ private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode";
+ private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie";
+
+ private readonly BaseItemRepository _repository;
+
+ private readonly Guid _library = Guid.NewGuid();
+ private readonly Guid _collection = Guid.NewGuid();
+ private readonly Guid _series = Guid.NewGuid();
+ private readonly Guid _season = Guid.NewGuid();
+ private readonly Guid _episode = Guid.NewGuid();
+
+ // A movie the collection links directly, so the direct-child case is covered alongside the nested one.
+ private readonly Guid _collectionMovie = Guid.NewGuid();
+
+ // In the same library but outside the collection, as the control the assertions are read against.
+ private readonly Guid _otherSeries = Guid.NewGuid();
+ private readonly Guid _otherEpisode = Guid.NewGuid();
+
+ public BaseItemRepositoryDescendantFilterTests()
+ {
+ using (var ctx = CreateDbContext())
+ {
+ Seed(ctx);
+ }
+
+ _repository = CreateBaseItemRepository(new ItemTypeLookup());
+ }
+
+ [Fact]
+ public void DescendantOfId_ReachesEpisodesOfALinkedSeries()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Episode]
+ });
+
+ Assert.Equal([_episode], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_ReturnsEveryLevelBelowTheCollection()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = _collection }).ToHashSet();
+
+ Assert.Equal(new[] { _series, _season, _episode, _collectionMovie }.Order(), ids.Order());
+ }
+
+ [Fact]
+ public void DescendantOfId_KeepsDirectlyLinkedChildren()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery
+ {
+ DescendantOfId = _collection,
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal([_collectionMovie], ids);
+ }
+
+ [Fact]
+ public void DescendantOfId_OnAnEmptyCollection_ReturnsNothing()
+ {
+ var ids = _repository.GetItemIdsList(new InternalItemsQuery { DescendantOfId = Guid.NewGuid() });
+
+ Assert.Empty(ids);
+ }
+
+ private void Seed(JellyfinDbContext context)
+ {
+ context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Shows", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _series, Type = SeriesType, Name = "Series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _season, Type = SeasonType, Name = "Season 1", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _episode, Type = EpisodeType, Name = "Episode 1" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _collectionMovie, Type = MovieType, Name = "Movie" });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherSeries, Type = SeriesType, Name = "Other series", IsFolder = true });
+ context.BaseItems.Add(new BaseItemEntity { Id = _otherEpisode, Type = EpisodeType, Name = "Other episode" });
+
+ // AncestorIds is a closure: production writes one row per ancestor, not just the parent.
+ AddAncestors(context, _series, _library);
+ AddAncestors(context, _season, _series, _library);
+ AddAncestors(context, _episode, _season, _series, _library);
+ AddAncestors(context, _collectionMovie, _library);
+ AddAncestors(context, _otherSeries, _library);
+ AddAncestors(context, _otherEpisode, _otherSeries, _library);
+
+ AddLink(context, _series, 0);
+ AddLink(context, _collectionMovie, 1);
+
+ context.SaveChanges();
+ }
+
+ private void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds)
+ {
+ foreach (var ancestorId in ancestorIds)
+ {
+ context.AncestorIds.Add(new AncestorId
+ {
+ ItemId = itemId,
+ ParentItemId = ancestorId,
+ Item = null!,
+ ParentItem = null!
+ });
+ }
+ }
+
+ private void AddLink(JellyfinDbContext context, Guid childId, int sortOrder)
+ {
+ context.LinkedChildren.Add(new LinkedChildEntity
+ {
+ ParentId = _collection,
+ ChildId = childId,
+ ChildType = LinkedChildType.Manual,
+ SortOrder = sortOrder
+ });
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
new file mode 100644
index 0000000000..91148501ce
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryItemValueTests.cs
@@ -0,0 +1,174 @@
+using System;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class BaseItemRepositoryItemValueTests : SqliteDbTestFixture
+{
+ private readonly BaseItemRepository _repository;
+ private readonly string _audioTypeName;
+ private readonly string _movieTypeName;
+
+ public BaseItemRepositoryItemValueTests()
+ {
+ var itemTypeLookup = new ItemTypeLookup();
+ _audioTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
+ _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
+ _repository = CreateBaseItemRepository(itemTypeLookup);
+ }
+
+ [Fact]
+ public void GetQueryFiltersLegacy_GroupsAndFiltersItemValues()
+ {
+ var firstItem = CreateMovieEntity(Guid.NewGuid(), "First");
+ var secondItem = CreateMovieEntity(Guid.NewGuid(), "Second");
+ var excludedItem = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Excluded Audio",
+ MediaType = "Audio",
+ IsMovie = false,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var firstTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Alpha",
+ CleanValue = "alpha"
+ };
+ var duplicateTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "alpha",
+ CleanValue = "alpha"
+ };
+ var secondTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Beta",
+ CleanValue = "beta"
+ };
+ var genre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Genre Leak",
+ CleanValue = "genre leak"
+ };
+ var excludedTag = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Tags,
+ Value = "Excluded Tag",
+ CleanValue = "excluded tag"
+ };
+ var excludedGenre = new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = ItemValueType.Genre,
+ Value = "Excluded Genre",
+ CleanValue = "excluded genre"
+ };
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(firstItem, secondItem, excludedItem);
+ context.ItemValues.AddRange(firstTag, duplicateTag, secondTag, genre, excludedTag, excludedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(firstItem, firstTag),
+ CreateMap(firstItem, duplicateTag),
+ CreateMap(secondItem, secondTag),
+ CreateMap(firstItem, genre),
+ CreateMap(excludedItem, excludedTag),
+ CreateMap(excludedItem, excludedGenre));
+ context.SaveChanges();
+ }
+
+ var result = _repository.GetQueryFiltersLegacy(new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
+ {
+ IncludeItemTypes = [BaseItemKind.Movie]
+ });
+
+ Assert.Equal(["Alpha", "Beta"], result.Tags);
+ Assert.Equal(["Genre Leak"], result.Genres);
+ }
+
+ [Fact]
+ public void GetGenreNames_GroupsAndFiltersMappedItemValues()
+ {
+ var movie = CreateMovieEntity(Guid.NewGuid(), "Movie");
+ var audio = new BaseItemEntity
+ {
+ Id = Guid.NewGuid(),
+ Type = _audioTypeName,
+ Name = "Audio",
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ var movieGenre = CreateItemValue(ItemValueType.Genre, "Movie Genre", "movie genre");
+ var duplicateMovieGenre = CreateItemValue(ItemValueType.Genre, "movie genre", "movie genre");
+ var musicGenre = CreateItemValue(ItemValueType.Genre, "Music Genre", "music genre");
+ var orphanedGenre = CreateItemValue(ItemValueType.Genre, "Orphaned Genre", "orphaned genre");
+
+ using (var context = CreateDbContext())
+ {
+ context.BaseItems.AddRange(movie, audio);
+ context.ItemValues.AddRange(movieGenre, duplicateMovieGenre, musicGenre, orphanedGenre);
+ context.ItemValuesMap.AddRange(
+ CreateMap(movie, movieGenre),
+ CreateMap(movie, duplicateMovieGenre),
+ CreateMap(audio, musicGenre));
+ context.SaveChanges();
+ }
+
+ Assert.Equal(["Movie Genre"], _repository.GetGenreNames());
+ Assert.Equal(["Music Genre"], _repository.GetMusicGenreNames());
+ }
+
+ private BaseItemEntity CreateMovieEntity(Guid id, string name)
+ {
+ return new BaseItemEntity
+ {
+ Id = id,
+ Type = _movieTypeName,
+ Name = name,
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+ }
+
+ private static ItemValueMap CreateMap(BaseItemEntity item, ItemValue itemValue)
+ {
+ return new ItemValueMap
+ {
+ ItemId = item.Id,
+ ItemValueId = itemValue.ItemValueId,
+ Item = item,
+ ItemValue = itemValue
+ };
+ }
+
+ private static ItemValue CreateItemValue(ItemValueType type, string value, string cleanValue)
+ {
+ return new ItemValue
+ {
+ ItemValueId = Guid.NewGuid(),
+ Type = type,
+ Value = value,
+ CleanValue = cleanValue
+ };
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
index 947cf54d85..fea743f08e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs
@@ -198,6 +198,78 @@ public sealed class ItemCountServiceTests : IDisposable
Assert.Equal(2, result[seriesB]);
}
+ [Fact]
+ public void GetChildCountBatch_FlatSeriesStructure_CountsEpisodesUnderTheirSeason()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: true, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+
+ // The series holds the season, not the episodes: counting those here would double them up.
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_SeasonFolderStructure_CountsEachEpisodeOnce()
+ {
+ var (seriesId, seasonId) = SeedSeries(flat: false, virtualEpisodes: false);
+
+ var result = _service.GetChildCountBatch([seriesId, seasonId], null);
+
+ Assert.Equal(2, result[seasonId]);
+ Assert.Equal(1, result[seriesId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_MissingEpisodes_CountedUnlessTheUserHidesThem()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+ var user = new User("count-test", "provider", "reset");
+
+ user.DisplayMissingEpisodes = true;
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+
+ // Nothing this user can open, so nothing to report.
+ user.DisplayMissingEpisodes = false;
+ Assert.Equal(0, _service.GetChildCountBatch([seasonId], user)[seasonId]);
+ }
+
+ [Fact]
+ public void GetChildCountBatch_NoUser_CountsMissingEpisodes()
+ {
+ var (_, seasonId) = SeedSeries(flat: false, virtualEpisodes: true);
+
+ Assert.Equal(2, _service.GetChildCountBatch([seasonId], null)[seasonId]);
+ }
+
+ private (Guid SeriesId, Guid SeasonId) SeedSeries(bool flat, bool virtualEpisodes)
+ {
+ var seriesId = Guid.NewGuid();
+ var seasonId = Guid.NewGuid();
+
+ using var context = CreateDbContext();
+ context.BaseItems.Add(CreateItem(seriesId));
+ context.BaseItems.Add(CreateItem(seasonId, seriesId));
+
+ // Flat: the episodes sit in the series folder, so ParentId points at the series and only
+ // SeasonId ties them to the season they belong to.
+ for (var i = 0; i < 2; i++)
+ {
+ var episode = CreateItem(Guid.NewGuid(), flat ? seriesId : seasonId);
+ episode.Type = "MediaBrowser.Controller.Entities.TV.Episode";
+ episode.IsFolder = false;
+ episode.IsVirtualItem = virtualEpisodes;
+ episode.SeasonId = seasonId;
+ context.BaseItems.Add(episode);
+ }
+
+ context.SaveChanges();
+
+ return (seriesId, seasonId);
+ }
+
private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId)
{
var user = new User("count-test", "provider", "reset");
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
new file mode 100644
index 0000000000..7997c6d771
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceServiceSaveImagesTests.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Entities;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public class ItemPersistenceServiceSaveImagesTests : SqliteDbTestFixture
+{
+ private readonly ItemPersistenceService _service;
+
+ public ItemPersistenceServiceSaveImagesTests()
+ {
+ _service = new ItemPersistenceService(
+ CreateDbContextFactory(),
+ Mock.Of<IServerApplicationHost>(),
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ReplacesThePreviousImages()
+ {
+ var itemId = Guid.NewGuid();
+ Seed(itemId);
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/first.jpg"), TestContext.Current.CancellationToken);
+ await _service.SaveImagesAsync(CreateItem(itemId, "/second.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ var paths = context.BaseItemImageInfos
+ .Where(e => e.ItemId.Equals(itemId))
+ .Select(e => e.Path)
+ .ToList();
+
+ Assert.Equal(["/second.jpg"], paths);
+ }
+
+ [Fact]
+ public async Task SaveImagesAsync_ItemDeletedFromUnderIt_IsANoOp()
+ {
+ // A scan can delete the item between the refresh reading it and the images being written. That
+ // must not fail the whole refresh, and must not leave the images of an item that is gone.
+ var itemId = Guid.NewGuid();
+
+ await _service.SaveImagesAsync(CreateItem(itemId, "/gone.jpg"), TestContext.Current.CancellationToken);
+
+ using var context = CreateDbContext();
+ Assert.Empty(context.BaseItemImageInfos.Where(e => e.ItemId.Equals(itemId)));
+ }
+
+ private static BaseItem CreateItem(Guid itemId, string imagePath)
+ => new Folder
+ {
+ Id = itemId,
+ ImageInfos = [new ItemImageInfo { Path = imagePath, Type = ImageType.Primary }]
+ };
+
+ private void Seed(Guid itemId)
+ {
+ using var context = CreateDbContext();
+ context.BaseItems.Add(new BaseItemEntity
+ {
+ Id = itemId,
+ Type = "Folder",
+ IsFolder = true
+ });
+ context.SaveChanges();
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
index 87efa8fea5..cfc9c9496c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs
@@ -1,4 +1,5 @@
using System;
+using System.Threading;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
@@ -58,6 +59,8 @@ public abstract class SqliteDbTestFixture : IDisposable
{
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+ factory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
+ .ReturnsAsync(CreateDbContext);
return factory.Object;
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
new file mode 100644
index 0000000000..30f7bed208
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PeopleValidatorPartitionTests.cs
@@ -0,0 +1,117 @@
+using System;
+using System.Collections.Generic;
+using Emby.Server.Implementations.Library.Validators;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library;
+
+/// <summary>
+/// Tests for how the people validator decides which credits need a person item and which person items
+/// nothing credits any more. Keying either half on the item's name rather than its id put the two halves
+/// in a loop that created, refreshed and deleted the same people on every run, so these pin the id.
+/// </summary>
+public class PeopleValidatorPartitionTests
+{
+ // Stands in for the real item-by-name id: derived from the credit name, case-insensitively, and
+ // from nothing else. The property that matters is that it does not depend on the item's own name.
+ private static Guid PersonId(string creditName)
+ {
+#pragma warning disable CA5351 // Do Not Use Broken Cryptographic Algorithms
+ var hash = System.Security.Cryptography.MD5.HashData(
+ System.Text.Encoding.Unicode.GetBytes(creditName.ToLowerInvariant()));
+#pragma warning restore CA5351 // Do Not Use Broken Cryptographic Algorithms
+ return new Guid(hash);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ProviderRenamedThePerson_KeepsThemAndCreatesNothing()
+ {
+ // The credit still says "AURORA"; the item it made has been renamed to "Aurora" by the provider
+ // that refreshed it. Nothing about the library changed, so nothing should be created or deleted.
+ var credits = new[] { "AURORA" };
+ var existing = new HashSet<Guid> { PersonId("AURORA") };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Theory]
+ // Every shape of rename seen in the wild on a real library.
+ [InlineData("AURORA")]
+ [InlineData("Amir AboulEla")]
+ [InlineData("Miguel Ángel Fuentes")]
+ [InlineData("a‐ha")]
+ [InlineData("윤현민")]
+ public void PartitionCreditsByPersonId_CreditWithAnItem_IsNeverBothCreatedAndDeleted(string creditName)
+ {
+ var existing = new HashSet<Guid> { PersonId(creditName) };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId([creditName], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditWithNoItem_IsCreated()
+ {
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["Wanted Person"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Equal(["Wanted Person"], newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_ItemNoCreditNames_IsDead()
+ {
+ var orphan = PersonId("Nobody Credits Me");
+ var existing = new HashSet<Guid> { PersonId("Credited"), orphan };
+
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(["Credited"], PersonId, existing);
+
+ Assert.Empty(newNames);
+ Assert.Equal([orphan], deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_CreditsNormalizingOntoOneId_CreateOneItem()
+ {
+ // "AURORA" and "Aurora" are one person as far as the item-by-name id is concerned, so exactly
+ // one of them should create the item and neither should end up dead.
+ var (newNames, deadIds) = PeopleValidator.PartitionCreditsByPersonId(
+ ["AURORA", "Aurora", "aurora"],
+ PersonId,
+ new HashSet<Guid>());
+
+ Assert.Single(newNames);
+ Assert.Empty(deadIds);
+ }
+
+ [Fact]
+ public void PartitionCreditsByPersonId_SecondRunAfterCreating_AsksForNothingFurther()
+ {
+ // The churn showed up as a run that never settled, so drive two rounds: whatever round one
+ // created must leave round two with nothing to do.
+ string[] credits = ["AURORA", "Amir AboulEla", "Miguel Ángel Fuentes"];
+ var existing = new HashSet<Guid>();
+
+ var (firstNames, firstDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+ Assert.Equal(3, firstNames.Count);
+ Assert.Empty(firstDead);
+
+ foreach (var created in firstNames)
+ {
+ existing.Add(PersonId(created));
+ }
+
+ var (secondNames, secondDead) = PeopleValidator.PartitionCreditsByPersonId(credits, PersonId, existing);
+
+ Assert.Empty(secondNames);
+ Assert.Empty(secondDead);
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
index feb2d8a625..67d924d152 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -62,6 +62,36 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.Equal(expectedId, actualId);
}
+ [Theory]
+ [InlineData("/media/Show/Season 01 [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 01 [anidbid-11111]", "AniDB", "11111")]
+ [InlineData("/media/Show/Season 02 [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show/Season 02 (anilistid=22222)", "AniList", "22222")]
+ [InlineData("/media/Show/Season 03 [anisearchid=33333]", "AniSearch", "33333")]
+ public void Resolve_SeasonFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
[Fact]
public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
{
@@ -140,6 +170,9 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(season.TryGetProviderId("AniDB", out _));
+ Assert.False(season.TryGetProviderId("AniList", out _));
+ Assert.False(season.TryGetProviderId("AniSearch", out _));
}
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
index a5a67046d1..f803c69af2 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SessionManager/SessionManagerTests.cs
@@ -1,6 +1,9 @@
using System;
+using System.Threading;
using System.Threading.Tasks;
+using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Enums;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
@@ -8,7 +11,9 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
+using MediaBrowser.Model.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
@@ -108,4 +113,136 @@ public class SessionManagerTests
return data;
}
+
+ [Fact]
+ public async Task SendMessageCommand_Should_ThrowSecurityException_WhenControllingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ await Assert.ThrowsAsync<SecurityException>(() => sessionManager.SendMessageCommand(
+ attackerSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "test exploit!" },
+ CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task SendMessageCommand_Should_Succeed_WhenAllowedToControlOtherUsers()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("controller", "default", "default");
+ attacker.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true);
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var controllingSession = await LogSessionActivity(sessionManager, attacker);
+
+ await sessionManager.SendMessageCommand(
+ controllingSession.Id,
+ victimSession.Id,
+ new MessageCommand { Header = "Custom Message", Text = "hello" },
+ CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task LogSessionActivity_Should_NotReuseAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ // Client name and device id are attacker controlled, so they must not identify a session on their own.
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.NotEqual(victimSession.Id, attackerSession.Id);
+ Assert.Equal(victim.Id, victimSession.UserId);
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_ThrowSecurityException_WhenAttachingAnotherUser()
+ {
+ var attacker = new User("attacker", "default", "default");
+ var victim = new User("victim", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.AddAdditionalUser(attackerSession.Id, attackerSession.Id, victim.Id));
+ }
+
+ [Fact]
+ public async Task AddAdditionalUser_Should_Succeed_WhenCallerIsAdministrator()
+ {
+ var admin = new User("admin", "default", "default");
+ admin.SetPermission(PermissionKind.IsAdministrator, true);
+ var guest = new User("guest", "default", "default");
+ await using var sessionManager = CreateSessionManager(admin, guest);
+
+ var adminSession = await LogSessionActivity(sessionManager, admin);
+
+ sessionManager.AddAdditionalUser(adminSession.Id, adminSession.Id, guest.Id);
+
+ Assert.Contains(adminSession.AdditionalUsers, i => i.UserId.Equals(guest.Id));
+ }
+
+ [Fact]
+ public async Task RemoveAdditionalUser_Should_ThrowSecurityException_WhenModifyingAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.RemoveAdditionalUser(attackerSession.Id, victimSession.Id, attacker.Id));
+ }
+
+ [Fact]
+ public async Task ReportCapabilities_Should_ThrowSecurityException_WhenReportingForAnotherUsersSession()
+ {
+ var victim = new User("victim", "default", "default");
+ var attacker = new User("attacker", "default", "default");
+ await using var sessionManager = CreateSessionManager(victim, attacker);
+
+ var victimSession = await LogSessionActivity(sessionManager, victim);
+ var attackerSession = await LogSessionActivity(sessionManager, attacker);
+
+ Assert.Throws<SecurityException>(() => sessionManager.ReportCapabilities(attackerSession.Id, victimSession.Id, new ClientCapabilities()));
+ }
+
+ private static Emby.Server.Implementations.Session.SessionManager CreateSessionManager(params User[] users)
+ {
+ var userManager = new Mock<IUserManager>();
+ foreach (var user in users)
+ {
+ userManager.Setup(i => i.GetUserById(user.Id)).Returns(user);
+ }
+
+ return new Emby.Server.Implementations.Session.SessionManager(
+ NullLogger<Emby.Server.Implementations.Session.SessionManager>.Instance,
+ Mock.Of<IEventManager>(),
+ Mock.Of<IUserDataManager>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILibraryManager>(),
+ userManager.Object,
+ Mock.Of<IMusicManager>(),
+ Mock.Of<IDtoService>(),
+ Mock.Of<IImageProcessor>(),
+ Mock.Of<IServerApplicationHost>(),
+ Mock.Of<IDeviceManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IHostApplicationLifetime>());
+ }
+
+ // All sessions are logged with the same client and device id on purpose, those values are taken
+ // from the request headers and are not bound to the access token of the calling user.
+ private static Task<SessionInfo> LogSessionActivity(ISessionManager sessionManager, User user)
+ => sessionManager.LogSessionActivity("Jellyfin Web", "1.0.0", "victim-tv-01", "device_name", "127.0.0.1", user);
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
index 32685556b2..05e8a40de1 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/PlayQueueManagerTests.cs
@@ -143,6 +143,36 @@ public class PlayQueueManagerTests
}
[Fact]
+ public void SetShuffleMode_SortedWhileAlreadySorted_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(3);
+ queue.SetPlayingItemByIndex(1);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(1, queue.PlayingItemIndex);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
+ public void SetShuffleMode_SortedTwiceAfterShuffle_KeepsPlayingItem()
+ {
+ var queue = CreateQueue(5);
+ queue.SetPlayingItemByIndex(2);
+ var expectedItemId = queue.GetPlayingItemId();
+
+ queue.SetShuffleMode(GroupShuffleMode.Shuffle);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+ queue.SetShuffleMode(GroupShuffleMode.Sorted);
+
+ Assert.Equal(GroupShuffleMode.Sorted, queue.ShuffleMode);
+ Assert.Equal(5, queue.GetPlaylist().Count);
+ Assert.Equal(expectedItemId, queue.GetPlayingItemId());
+ }
+
+ [Fact]
public void SetPlayingItemByIndex_InBounds_SetsPlayingItem()
{
var queue = CreateQueue(2);
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
new file mode 100644
index 0000000000..b1221f6f71
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/SyncPlayManagerTests.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Threading;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayManager = Emby.Server.Implementations.SyncPlay.SyncPlayManager;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class SyncPlayManagerTests
+{
+ [Fact]
+ public void LeaveGroup_AfterJoiningTheSameGroupTwice_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ // A client that re-sends Join for the group it is already in must not be counted twice.
+ harness.Manager.JoinGroup(harness.Session, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void LeaveGroup_AfterASingleJoin_ClearsTheActiveSessionCounter()
+ {
+ var harness = new ManagerHarness();
+
+ harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ [Fact]
+ public void IsUserActive_WithTwoSessionsOfTheSameUser_TracksBothSeparately()
+ {
+ var harness = new ManagerHarness();
+ var second = harness.CreateSession("session-2");
+
+ var info = harness.Manager.NewGroup(harness.Session, new NewGroupRequest("group"), CancellationToken.None);
+ harness.Manager.JoinGroup(second, new JoinGroupRequest(info.GroupId), CancellationToken.None);
+
+ harness.Manager.LeaveGroup(harness.Session, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.True(harness.Manager.IsUserActive(harness.User.Id));
+
+ harness.Manager.LeaveGroup(second, new LeaveGroupRequest(), CancellationToken.None);
+ Assert.False(harness.Manager.IsUserActive(harness.User.Id));
+ }
+
+ private sealed class ManagerHarness
+ {
+ private readonly Mock<ISessionManager> _sessionManager = new();
+
+ public ManagerHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ User = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(User);
+
+ Manager = new SyncPlayManager(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ _sessionManager.Object,
+ libraryManager.Object);
+
+ Session = CreateSession("session-1");
+ }
+
+ public SyncPlayManager Manager { get; }
+
+ public User User { get; }
+
+ public SessionInfo Session { get; }
+
+ public SessionInfo CreateSession(string id)
+ {
+ return new SessionInfo(_sessionManager.Object, NullLogger.Instance)
+ {
+ Id = id,
+ UserId = User.Id,
+ UserName = User.Username
+ };
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
new file mode 100644
index 0000000000..0cccd5d4ca
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/SyncPlay/WaitingGroupStateTests.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Session;
+using MediaBrowser.Controller.SyncPlay.GroupStates;
+using MediaBrowser.Controller.SyncPlay.PlaybackRequests;
+using MediaBrowser.Controller.SyncPlay.Requests;
+using MediaBrowser.Model.SyncPlay;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using SyncPlayGroup = Emby.Server.Implementations.SyncPlay.Group;
+
+namespace Jellyfin.Server.Implementations.Tests.SyncPlay;
+
+public class WaitingGroupStateTests
+{
+ [Fact]
+ public void Ready_ClientResumedWithLowPing_AppliesTheDefaultPingFloorInMilliseconds()
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ // Both members report a ping well under the default, so the floor is what decides the delay.
+ group.UpdatePing(harness.First, 10);
+ group.UpdatePing(harness.Second, 10);
+
+ group.PositionTicks = TimeSpan.FromMinutes(5).Ticks;
+ group.LastActivity = DateTime.UtcNow;
+ group.SetBuffering(harness.First, true);
+ group.SetBuffering(harness.Second, false);
+
+ var state = new WaitingGroupState(NullLoggerFactory.Instance) { ResumePlaying = true };
+
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new ReadyGroupRequest(DateTime.UtcNow, group.PositionTicks, true, harness.PlaylistItemId),
+ group,
+ GroupStateType.Waiting,
+ harness.First,
+ CancellationToken.None);
+
+ // DefaultPing is expressed in milliseconds, so the floor must be converted before being
+ // compared against a tick count. Without the conversion the floor is 500 ticks (0.05 ms)
+ // and never applies.
+ var scheduledDelay = group.LastActivity - before;
+ Assert.True(
+ scheduledDelay >= TimeSpan.FromMilliseconds(group.DefaultPing),
+ $"expected a resume delay of at least {group.DefaultPing} ms, got {scheduledDelay.TotalMilliseconds} ms");
+ }
+
+ [Theory]
+ [InlineData(4_000_000_000L)]
+ [InlineData(1_000_000_000_000_000L)]
+ [InlineData(long.MaxValue)]
+ [InlineData(-1L)]
+ public void UpdatePing_ClientReportsAnUnusablePing_IsClampedAndCannotStallTheGroup(long reportedPing)
+ {
+ var harness = new GroupHarness();
+ var group = harness.Group;
+
+ group.UpdatePing(harness.First, reportedPing);
+
+ Assert.InRange(group.GetHighestPing(), 0, group.MaxPing);
+
+ // The reported ping is scaled into the group's resume point, so an unclamped value either
+ // pushes playback months out or overflows the arithmetic outright.
+ var state = new PlayingGroupState(NullLoggerFactory.Instance);
+ var before = DateTime.UtcNow;
+ state.HandleRequest(
+ new UnpauseGroupRequest(),
+ group,
+ GroupStateType.Paused,
+ harness.First,
+ CancellationToken.None);
+
+ Assert.InRange(group.LastActivity - before, TimeSpan.Zero, TimeSpan.FromMinutes(1));
+ }
+
+ private sealed class GroupHarness
+ {
+ public GroupHarness()
+ {
+ var userManager = new Mock<IUserManager>();
+ var sessionManager = new Mock<ISessionManager>();
+ var libraryManager = new Mock<ILibraryManager>();
+
+ var user = new User("tester", "auth-provider", "pwdreset-provider");
+ userManager.Setup(m => m.GetUserById(It.IsAny<Guid>())).Returns(user);
+
+ var item = new Mock<BaseItem>();
+ item.Setup(i => i.IsVisibleStandalone(It.IsAny<User>())).Returns(true);
+ item.Object.RunTimeTicks = TimeSpan.FromHours(2).Ticks;
+ libraryManager.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns(item.Object);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayCommand(It.IsAny<string>(), It.IsAny<SendCommand>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
+ sessionManager
+ .Setup(m => m.SendSyncPlayGroupUpdate(It.IsAny<string>(), It.IsAny<GroupUpdate<GroupStateUpdate>>(), It.IsAny<CancellationToken>()))
+ .Returns(Task.CompletedTask);
+
+ Group = new SyncPlayGroup(
+ NullLoggerFactory.Instance,
+ userManager.Object,
+ sessionManager.Object,
+ libraryManager.Object);
+
+ First = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ {
+ Id = "first",
+ UserId = user.Id,
+ UserName = "first"
+ };
+ Second = new SessionInfo(sessionManager.Object, NullLogger.Instance)
+ {
+ Id = "second",
+ UserId = user.Id,
+ UserName = "second"
+ };
+
+ Group.CreateGroup(First, new NewGroupRequest("group"), CancellationToken.None);
+ Group.SessionJoin(Second, new JoinGroupRequest(Group.GroupId), CancellationToken.None);
+ Group.SetPlayQueue(new List<Guid> { Guid.NewGuid() }, 0, 0);
+ PlaylistItemId = Group.PlayQueue.GetPlayingItemPlaylistId();
+ }
+
+ public SyncPlayGroup Group { get; }
+
+ public SessionInfo First { get; }
+
+ public SessionInfo Second { get; }
+
+ public Guid PlaylistItemId { get; }
+ }
+}
diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
index 2de6408cc6..79b9d1e2c5 100644
--- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryStructureControllerTests.cs
@@ -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;
@@ -26,6 +29,45 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
}
[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()
{
@@ -114,6 +156,58 @@ public sealed class LibraryStructureControllerTests : IClassFixture<JellyfinAppl
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task DeleteLibrary_PathTraversal_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.DeleteAsync($"Library/VirtualFolders?name={Uri.EscapeDataString(name)}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData(".")]
+ [InlineData("test/../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalNewName_BadRequest(string newName)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name=test&newName={Uri.EscapeDataString(newName)}",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Theory]
+ [Priority(1)]
+ [InlineData("..")]
+ [InlineData("../..")]
+ [InlineData("/var/lib/jellyfin/data")]
+ public async Task RenameLibrary_PathTraversalName_NotFound(string name)
+ {
+ var client = _factory.CreateClient();
+ client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client));
+
+ using var response = await client.PostAsync(
+ $"Library/VirtualFolders/Name?name={Uri.EscapeDataString(name)}&newName=renamed",
+ null,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
+ }
+
[Fact]
[Priority(1)]
public async Task DeleteLibrary_Valid_Success()
diff --git a/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
new file mode 100644
index 0000000000..3bd8581a5f
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/Migrations/CodeMigrationTests.cs
@@ -0,0 +1,112 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Server.Migrations;
+using Jellyfin.Server.Migrations.Stages;
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.Migrations;
+
+public class CodeMigrationTests
+{
+ [Fact]
+ public async Task Perform_LeavesApplicationSingletonsAlive()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var applicationSingleton = serviceProvider.GetRequiredService<ApplicationSingleton>();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ var performed = TestMigration.Performed;
+ Assert.NotNull(performed);
+ // The migration has to run against the applications own services, and they have to outlive it.
+ Assert.Same(applicationSingleton, performed.Singleton);
+ Assert.False(applicationSingleton.IsDisposed);
+ Assert.Same(applicationSingleton, serviceProvider.GetRequiredService<ApplicationSingleton>());
+ // Services created for the migration itself are still owned by the migration.
+ Assert.True(performed.Transient.IsDisposed);
+ // The startup logger has to stay attached to the topic of the running migration.
+ Assert.Same(logger.Topic, performed.Logger.Topic);
+ }
+
+ [Fact]
+ public async Task Perform_DoesNotLeakTheMigrationTopic()
+ {
+ var services = new ServiceCollection()
+ .AddLogging()
+ .RegisterStartupLogger()
+ .AddSingleton<ApplicationSingleton>()
+ .AddTransient<MigrationTransient>();
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var logger = new StartupLogger(NullLogger.Instance).BeginGroup($"Test migration");
+
+ var migration = new CodeMigration(
+ typeof(TestMigration),
+ new JellyfinMigrationAttribute("2026-09-05T10:00:00", nameof(TestMigration)),
+ null);
+ await migration.Perform(serviceProvider, logger, CancellationToken.None);
+
+ // The topic belongs to the migration that ran, so loggers resolved afterwards must not still write into it.
+ Assert.Null(serviceProvider.GetRequiredService<IStartupLogger<CodeMigrationTests>>().Topic);
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ private sealed class ApplicationSingleton : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class MigrationTransient : IDisposable
+ {
+ public bool IsDisposed { get; private set; }
+
+ public void Dispose()
+ {
+ IsDisposed = true;
+ }
+ }
+
+ private sealed class TestMigration : IAsyncMigrationRoutine
+ {
+ public TestMigration(ApplicationSingleton singleton, MigrationTransient transient, IStartupLogger<TestMigration> logger)
+ {
+ Singleton = singleton;
+ Transient = transient;
+ Logger = logger;
+ }
+
+ public static TestMigration? Performed { get; private set; }
+
+ public ApplicationSingleton Singleton { get; }
+
+ public MigrationTransient Transient { get; }
+
+ public IStartupLogger<TestMigration> Logger { get; }
+
+ public Task PerformAsync(CancellationToken cancellationToken)
+ {
+ Performed = this;
+ return Task.CompletedTask;
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
new file mode 100644
index 0000000000..c2894e9647
--- /dev/null
+++ b/tests/Jellyfin.Server.Tests/ServerSetupApp/StartupLoggerTests.cs
@@ -0,0 +1,54 @@
+using Jellyfin.Server.ServerSetupApp;
+using Microsoft.Extensions.Logging.Abstractions;
+using Xunit;
+
+namespace Jellyfin.Server.Tests.ServerSetupApp;
+
+public class StartupLoggerTests
+{
+ [Fact]
+ public void BeginAmbientTopic_AttachesNewLoggersToTheTopic()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(migration.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+ }
+
+ [Fact]
+ public void BeginAmbientTopic_RestoresThePreviousTopic()
+ {
+ var root = new StartupLogger(NullLogger.Instance);
+ var outer = root.BeginGroup($"Outer");
+ var inner = outer.BeginGroup($"Inner");
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+
+ using (StartupLogger.BeginAmbientTopic(outer.Topic))
+ {
+ using (StartupLogger.BeginAmbientTopic(inner.Topic))
+ {
+ Assert.Same(inner.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ // Leaving a nested topic has to fall back to the enclosing one, not to the setup UI root.
+ Assert.Same(outer.Topic, new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ Assert.Null(new StartupLogger(NullLogger.Instance).Topic);
+ }
+
+ [Fact]
+ public void BeginGroup_KeepsAnExplicitTopicOverTheAmbientOne()
+ {
+ var migration = new StartupLogger(NullLogger.Instance).BeginGroup($"Migration");
+ var unrelated = new StartupLogger(NullLogger.Instance).BeginGroup($"Unrelated");
+
+ using (StartupLogger.BeginAmbientTopic(migration.Topic))
+ {
+ Assert.Same(unrelated.Topic, unrelated.With(NullLogger.Instance).Topic);
+ }
+ }
+}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
index a04b37f215..3767b5c954 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs
@@ -124,6 +124,27 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
}
[Fact]
+ public void Fetch_Valid_MultiEpisode_Unordered_Success()
+ {
+ var result = new MetadataResult<Episode>()
+ {
+ Item = new Episode()
+ };
+
+ _parser.Fetch(result, "Test Data/Rising-Reversed.nfo", CancellationToken.None);
+
+ var item = result.Item;
+ // The episodedetails blocks are stored in descending order, the merged episode must still be in ascending order
+ Assert.Equal("Rising (1) / Rising (2)", item.Name);
+ Assert.Equal(1, item.IndexNumber);
+ Assert.Equal(2, item.IndexNumberEnd);
+ Assert.Equal(1, item.ParentIndexNumber);
+ Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview);
+ Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate);
+ Assert.Equal(2004, item.ProductionYear);
+ }
+
+ [Fact]
public void Fetch_Valid_MultiEpisode_With_Missing_Tags_Success()
{
var result = new MetadataResult<Episode>()
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
new file mode 100644
index 0000000000..6dbab13566
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/Rising-Reversed.nfo
@@ -0,0 +1,43 @@
+<episodedetails>
+ <title>Rising (2)</title>
+ <season>1</season>
+ <episode>2</episode>
+ <aired>2004-07-16</aired>
+ <plot>Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25334.jpg</thumb>
+ <watched>false</watched>
+ <rating>7.9</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails><episodedetails>
+ <title>Rising (1)</title>
+ <season>1</season>
+ <episode>1</episode>
+ <aired>2004-07-16</aired>
+ <plot>A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.</plot>
+ <thumb>https://artworks.thetvdb.com/banners/episodes/70851/25333.jpg</thumb>
+ <watched>false</watched>
+ <rating>8.0</rating>
+ <actor>
+ <name>Joe Flanigan</name>
+ <role>John Sheppard</role>
+ <order>0</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/5AA1ORKIsnMakT6fCVy3JKlzMs6.jpg</thumb>
+ </actor>
+ <actor>
+ <name>David Hewlett</name>
+ <role>Rodney McKay</role>
+ <order>1</order>
+ <thumb>https://image.tmdb.org/t/p/w300_and_h450_bestv2/hUcYyssAPCqnZ4GjolhOWXHTWSa.jpg</thumb>
+ </actor>
+</episodedetails>