aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs76
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs425
-rw-r--r--tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs149
-rw-r--r--tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs41
-rw-r--r--tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs51
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs46
-rw-r--r--tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs21
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs215
-rw-r--r--tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs275
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs50
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs199
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs149
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs186
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs250
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs83
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs131
16 files changed, 2268 insertions, 79 deletions
diff --git a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs
index 1f06e8fde6..5f5f273f12 100644
--- a/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs
+++ b/tests/Jellyfin.Api.Tests/Controllers/DynamicHlsControllerTests.cs
@@ -1,5 +1,9 @@
using System;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Api.Controllers;
+using MediaBrowser.Controller.MediaEncoding;
+using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers
@@ -41,5 +45,77 @@ namespace Jellyfin.Api.Tests.Controllers
return data;
}
+
+ [Fact]
+ public async Task WaitForActiveTranscodingRequests_WaitsUntilRequestCompletes()
+ {
+ var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance)
+ {
+ ActiveRequestCount = 1
+ };
+
+ var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None);
+ Assert.False(waitTask.IsCompleted);
+
+ job.DecrementActiveRequestCount();
+
+ await waitTask;
+ }
+
+ [Fact]
+ public async Task WaitForActiveTranscodingRequests_WaitsForEveryRequest()
+ {
+ var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance)
+ {
+ ActiveRequestCount = 2
+ };
+
+ var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None);
+ job.DecrementActiveRequestCount();
+
+ await Task.Delay(150, TestContext.Current.CancellationToken);
+ Assert.False(waitTask.IsCompleted);
+
+ job.DecrementActiveRequestCount();
+
+ await waitTask;
+ }
+
+ [Fact]
+ public async Task WaitForActiveTranscodingRequests_ReturnsWithoutAnActiveRequest()
+ {
+ var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance);
+
+ await DynamicHlsController.WaitForActiveTranscodingRequests(job, CancellationToken.None);
+ await DynamicHlsController.WaitForActiveTranscodingRequests(null, CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task WaitForActiveTranscodingRequests_ObservesCancellation()
+ {
+ var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance)
+ {
+ ActiveRequestCount = 1
+ };
+ using var cancellationTokenSource = new CancellationTokenSource();
+
+ var waitTask = DynamicHlsController.WaitForActiveTranscodingRequests(job, cancellationTokenSource.Token);
+ await cancellationTokenSource.CancelAsync();
+
+ await Assert.ThrowsAnyAsync<OperationCanceledException>(() => waitTask);
+ }
+
+ [Fact]
+ public async Task ActiveRequestCount_UpdatesAtomically()
+ {
+ const int RequestCount = 1000;
+ var job = new TranscodingJob(NullLogger<TranscodingJob>.Instance);
+
+ await Task.WhenAll(
+ Task.Run(() => Parallel.For(0, RequestCount, _ => job.IncrementActiveRequestCount())),
+ Task.Run(() => Parallel.For(0, RequestCount, _ => job.DecrementActiveRequestCount())));
+
+ Assert.Equal(0, job.ActiveRequestCount);
+ }
}
}
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
index a003be4d96..fe824eddd9 100644
--- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
+++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
@@ -1,13 +1,21 @@
using System;
using System.Globalization;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
+using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.MediaInfo;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
@@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers
{
public class MediaInfoHelperTests
{
- private static MediaInfoHelper CreateHelper()
+ private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts";
+
+ private static MediaInfoHelper CreateHelper(
+ IMediaSourceManager? mediaSourceManager = null,
+ IServerApplicationHost? appHost = null,
+ string baseUrl = "")
{
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager
+ .Setup(x => x.GetConfiguration(It.IsAny<string>()))
+ .Returns(new NetworkConfiguration { BaseUrl = baseUrl });
+
return new MediaInfoHelper(
Mock.Of<IUserManager>(),
Mock.Of<ILibraryManager>(),
- Mock.Of<IMediaSourceManager>(),
+ mediaSourceManager ?? Mock.Of<IMediaSourceManager>(),
Mock.Of<IMediaEncoder>(),
- Mock.Of<IServerConfigurationManager>(),
+ serverConfigurationManager.Object,
Mock.Of<ILogger<MediaInfoHelper>>(),
Mock.Of<INetworkManager>(),
- Mock.Of<IDeviceManager>());
+ Mock.Of<IDeviceManager>(),
+ appHost ?? Mock.Of<IServerApplicationHost>());
}
private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
@@ -95,5 +114,403 @@ namespace Jellyfin.Api.Tests.Helpers
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
+
+ [Fact]
+ public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly()
+ {
+ const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath;
+
+ var sharedLiveSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = LocalPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager
+ .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(sharedLiveSource);
+
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com");
+
+ var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
+
+ var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>(), liveStreamId: "live-1").ConfigureAwait(true);
+
+ Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path);
+
+ // The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten.
+ Assert.Equal(LocalPath, sharedLiveSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_RewritesReturnedLiveStreamPath()
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = "http://127.0.0.1:8096" + LiveStreamFilesPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl()
+ {
+ const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath;
+
+ // Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it
+ // and must not be able to affect it.
+ var localSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = LocalPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager
+ .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() =>
+ {
+ // Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the
+ // helper mutates must be a deserialized copy, never localSource itself.
+ var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(localSource))!;
+ return new LiveStreamResponse(clone);
+ });
+
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://jellyfin.example.com");
+
+ var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path);
+
+ // The mock now actually derives its response from localSource, so this assertion is meaningful:
+ // rewriting the returned clone must never mutate the object localSource represents.
+ Assert.Equal(LocalPath, localSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged()
+ {
+ // A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/
+ // route shape without actually being hosted by this server. Only opened streams (which always
+ // carry a LiveStreamId) are eligible for rewriting.
+ const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath;
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = ForeignPath
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal(ForeignPath, response.MediaSource.Path);
+ }
+
+ [Theory]
+ [InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")]
+ [InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")]
+ [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")]
+ [InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")]
+ public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path)
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = protocol,
+ Path = path
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal(path, response.MediaSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix()
+ {
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged()
+ {
+ const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath;
+
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = LocalPath
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal(LocalPath, response.MediaSource.Path);
+ }
+
+ [Fact]
+ public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort()
+ {
+ // Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API
+ // URL carries an explicit non-default port alongside the configured BaseUrl.
+ var mediaSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin");
+
+ var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
+
+ Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path);
+ }
+
+ [Fact]
+ public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases()
+ {
+ const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath;
+
+ // Both requests resolve the same live stream; the manager hands back its own shared instance each time.
+ var sharedLiveSource = new MediaSourceInfo
+ {
+ Id = "abc",
+ Protocol = MediaProtocol.Http,
+ Path = LocalPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager
+ .Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(sharedLiveSource);
+
+ var requestA = new DefaultHttpContext().Request;
+ var requestB = new DefaultHttpContext().Request;
+
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com");
+ appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com");
+
+ var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
+
+ var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true);
+ var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true);
+
+ Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path);
+ Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path);
+
+ // Neither request's rewrite may leak into the other's response or into the shared instance.
+ Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path);
+ Assert.Equal(LocalPath, sharedLiveSource.Path);
+ }
+
+ [Fact]
+ public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath()
+ {
+ // Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246):
+ // it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then
+ // merges by replacing result.MediaSources with the opened source. Building a full controller fixture
+ // is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper
+ // calls the controller makes and asserts the merged source is the rewritten one.
+ var itemId = Guid.NewGuid();
+ var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture);
+
+ // The pre-open placeholder source carries a different local path than the one OpenMediaSource
+ // eventually returns, so the final assertion can prove the merge picked up the freshly opened
+ // source rather than the stale placeholder.
+ var requiresOpeningSource = new MediaSourceInfo
+ {
+ Id = sourceId,
+ Protocol = MediaProtocol.Http,
+ Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts",
+ RequiresOpening = true,
+ LiveStreamId = string.Empty
+ };
+
+ var openedSource = new MediaSourceInfo
+ {
+ Id = sourceId,
+ Protocol = MediaProtocol.Http,
+ Path = "http://172.19.0.3:8096" + LiveStreamFilesPath,
+ LiveStreamId = "livestream-1"
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager
+ .Setup(x => x.GetPlaybackMediaSources(It.IsAny<BaseItem>(), It.IsAny<User>(), true, true, It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new[] { requiresOpeningSource });
+ mediaSourceManager
+ .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(() =>
+ {
+ // MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning
+ // it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so
+ // the in-place rewrite below can't be observed on openedSource itself.
+ var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(openedSource))!;
+ return new LiveStreamResponse(clone);
+ });
+
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com");
+
+ var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
+
+ var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>()).ConfigureAwait(true);
+
+ var mediaSource = info.MediaSources[0];
+ Assert.True(mediaSource.RequiresOpening);
+ var preOpenPath = mediaSource.Path;
+
+ var openStreamResult = await helper.OpenMediaSource(
+ new DefaultHttpContext(),
+ new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true);
+
+ // MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource };
+ info.MediaSources = new[] { openStreamResult.MediaSource };
+
+ Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path);
+ Assert.NotEqual(preOpenPath, info.MediaSources[0].Path);
+
+ // The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed.
+ Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path);
+ }
+
+ [Theory]
+ [InlineData(
+ "https://media.example.com",
+ "http://172.19.0.3:8096" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com/",
+ "http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1",
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com" + LiveStreamFilesPath + "?token=1")]
+ [InlineData(
+ "https://media.example.com",
+ "http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment",
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com",
+ "https://172.19.0.3:8920" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com",
+ "http://192.168.1.10:8096" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com:8920",
+ "http://172.19.0.3:8096" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "",
+ "https://media.example.com:8920" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com",
+ "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "/jellyfin",
+ "https://media.example.com/jellyfin" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://jellyfin",
+ "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "/jellyfin",
+ "https://jellyfin/jellyfin" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com/jellyfin",
+ "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "/jellyfin",
+ "https://media.example.com/jellyfin" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com/jellyfin/",
+ "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "/jellyfin",
+ "https://media.example.com/jellyfin" + LiveStreamFilesPath)]
+ [InlineData(
+ "https://media.example.com",
+ "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath,
+ MediaProtocol.Http,
+ "/jellyfin",
+ null)]
+ [InlineData(
+ "https://media.example.com",
+ "/media/livetv/buffer/abc/stream.ts",
+ MediaProtocol.File,
+ "",
+ null)]
+ [InlineData(
+ "https://media.example.com",
+ "not a uri",
+ MediaProtocol.Http,
+ "",
+ null)]
+ public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected)
+ {
+ var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl);
+
+ Assert.Equal(expected, result);
+ }
+
+ private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "")
+ {
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager
+ .Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
+ .ReturnsAsync(new LiveStreamResponse(mediaSource));
+
+ var appHost = new Mock<IServerApplicationHost>();
+ appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns(smartApiUrl);
+
+ return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl);
+ }
}
}
diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
index 258cf326ca..e34eb0bda3 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -1,19 +1,25 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
+using System.Threading.Tasks;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
+using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using MediaBrowser.Model.Querying;
using Moq;
using Xunit;
@@ -293,6 +299,85 @@ public class BaseItemTests
Times.Never);
}
+ [Theory]
+ // A version file the scan just found beside the episode is not linked yet, so it does not count
+ // towards MediaSourceCount. The episode still has to refresh its owned items, as that is what
+ // creates the item for the version and links it.
+ [InlineData(true, false, true)]
+ [InlineData(false, true, true)]
+ [InlineData(false, false, false)]
+ public void SupportsOwnedItems_EpisodeWithResolvedVersionOrPart_IsTrue(bool hasLocalVersion, bool isStacked, bool expected)
+ {
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var episode = new Episode
+ {
+ Id = Guid.NewGuid(),
+ Path = "/TV/Show/Season 1/S01E01 - 1080p.mkv",
+ LocalAlternateVersions = hasLocalVersion ? ["/TV/Show/Season 1/S01E01 - 720p.mkv"] : [],
+ AdditionalParts = isStacked ? ["/TV/Show/Season 1/S01E01 - 1080p-part2.mkv"] : []
+ };
+
+ var property = typeof(Episode).GetProperty("SupportsOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(property);
+
+ Assert.Equal(expected, (bool)property!.GetValue(episode)!);
+ }
+
+ [Theory]
+ // The season folder is the season's own, so the extras that sit in it are the season's. Whether
+ // the season holds one episode or two must not decide where its extras show up.
+ [InlineData(false, false)]
+ // An episode with a folder of its own keeps the extras in it, as nothing else searches there
+ [InlineData(true, true)]
+ public async Task RefreshedOwnedItems_EpisodeInAContainersOwnFolder_LeavesExtrasToTheContainer(bool episodeHasOwnFolder, bool expectSearch)
+ {
+ var seasonPath = Path.Combine("TV", "Show", "Season 1");
+ var episodeFolder = episodeHasOwnFolder ? Path.Combine(seasonPath, "S01E01") : seasonPath;
+ var episodePath = Path.Combine(episodeFolder, "S01E01 - 1080p.mkv");
+
+ // The season needs a parent of its own, as an item without one maintains no owned items
+ var season = new Season { Id = Guid.NewGuid(), ParentId = Guid.NewGuid(), Path = seasonPath };
+ var episode = new Episode
+ {
+ Id = Guid.NewGuid(),
+ ParentId = season.Id,
+ Path = episodePath,
+ // A version file is what makes an episode maintain owned items at all
+ LocalAlternateVersions = [Path.Combine(episodeFolder, "S01E01 - 720p.mkv")]
+ };
+
+ var mediaSourceManager = new Mock<IMediaSourceManager>();
+ mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
+ BaseItem.MediaSourceManager = mediaSourceManager.Object;
+
+ var fileSystem = new Mock<IFileSystem>();
+ fileSystem.Setup(x => x.FileExists(It.IsAny<string>())).Returns(true);
+ BaseItem.FileSystem = fileSystem.Object;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(x => x.GetItemById(season.Id)).Returns(season);
+ libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
+ libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
+ libraryManager.Setup(x => x.GetItemList(It.IsAny<InternalItemsQuery>())).Returns(Array.Empty<BaseItem>());
+ libraryManager.Setup(x => x.FindExtras(It.IsAny<BaseItem>(), It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()))
+ .Returns(Array.Empty<BaseItem>());
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var method = typeof(BaseItem).GetMethod("RefreshedOwnedItems", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ var options = new MetadataRefreshOptions(Mock.Of<IDirectoryService>());
+ await (Task<bool>)method!.Invoke(episode, [options, Array.Empty<FileSystemMetadata>(), CancellationToken.None])!;
+
+ libraryManager.Verify(
+ x => x.FindExtras(episode, It.IsAny<IReadOnlyList<FileSystemMetadata>>(), It.IsAny<IDirectoryService>()),
+ expectSearch ? Times.Once() : Times.Never());
+ }
+
private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
{
var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
@@ -443,4 +528,68 @@ public class BaseItemTests
Assert.Equal(1982, trailer.ProductionYear);
Assert.Equal(new DateTime(1982, 6, 25, 0, 0, 0, DateTimeKind.Utc), trailer.PremiereDate);
}
+
+ [Theory]
+ // An extra named after a version belongs to that version, not to the primary whose name it
+ // also starts with
+ [InlineData("/Movies/Movie/Movie - 4K-trailer.mkv", 2)]
+ [InlineData("/Movies/Movie/Movie - 1080p-behindthescenes.mkv", 1)]
+ // Named after the movie rather than one of its versions
+ [InlineData("/Movies/Movie/Movie-trailer.mkv", 0)]
+ // In an extras folder, so named after nothing in particular
+ [InlineData("/Movies/Movie/trailers/Official.mkv", 0)]
+ // A version name is only a match when it is followed by the extra's own suffix
+ [InlineData("/Movies/Movie/Movie - 4Kish-trailer.mkv", 0)]
+ public void GetOwnerIdForExtra_AssignsExtraToItsVersion(string extraPath, int expectedVersion)
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+ var expectedId = expectedVersion switch
+ {
+ 1 => alt1.Id,
+ 2 => alt2.Id,
+ _ => primary.Id
+ };
+
+ var method = typeof(Video).GetMethod("GetOwnerIdForExtra", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ var ownerId = (Guid)method!.Invoke(primary, [new Video { Id = Guid.NewGuid(), Path = extraPath }])!;
+
+ Assert.Equal(expectedId, ownerId);
+ }
+
+ [Fact]
+ public void GetExtraOwnerIds_FromAnyVersion_CoversEveryVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetExtraOwnerIds", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // An extra is owned by the one version it is named after, and the extras of the movie as a
+ // whole are owned by the primary, so every version has to read all of them back
+ foreach (var version in new[] { primary, alt1, alt2 })
+ {
+ var ids = (Guid[])method!.Invoke(version, null)!;
+
+ Assert.Equal(3, ids.Length);
+ Assert.Contains(primary.Id, ids);
+ Assert.Contains(alt1.Id, ids);
+ Assert.Contains(alt2.Id, ids);
+ }
+ }
+
+ [Fact]
+ public void GetOwnedVersionIds_CoversEveryLocalVersion()
+ {
+ var (primary, alt1, alt2) = SetupVersionGroup();
+
+ var method = typeof(Video).GetMethod("GetOwnedVersionIds", BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.NotNull(method);
+
+ // The extras of all versions are maintained together, so all of them have to be read back
+ var ids = (Guid[])method!.Invoke(primary, null)!;
+
+ Assert.Equal([primary.Id, alt1.Id, alt2.Id], ids);
+ }
}
diff --git a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
index 71b6551d0f..2b009b4673 100644
--- a/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
+++ b/tests/Jellyfin.Controller.Tests/MediaEncoding/EncodingHelperTests.cs
@@ -223,12 +223,51 @@ public class EncodingHelperTests
Assert.Contains("-ar " + expectedSampleRate, args, StringComparison.Ordinal);
}
- private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate)
+ [Theory]
+ [InlineData("wav")]
+ [InlineData("flac")]
+ [InlineData("mp3")]
+ public void GetProgressiveAudioFullCommandLine_PcmInRealContainer_KeepsContainerMuxer(string outputContainer)
+ {
+ // A pcm_* encoder must not drag the raw muxer into a container that writes its own header,
+ // or the client gets headerless PCM behind the container's content type.
+ var state = BuildAudioState("pcm_s16le", 48000, outputContainer);
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.DoesNotContain("-f s16le", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_PcmInPcmContainer_ForcesRawMuxer()
+ {
+ // The raw-PCM route added in #10321 for I2S/MCU clients must keep working.
+ var state = BuildAudioState("pcm_s16le", 48000, "pcm");
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.Contains("-f s16le", args, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void GetProgressiveAudioFullCommandLine_PcmWithoutBitrate_EmitsNoEmptySampleRate()
+ {
+ // AudioBitRate is optional; it used to be emitted as `-ar <null>`, producing a bare `-ar`
+ // that made ffmpeg abort with "Expected number for ar" and the request fail with HTTP 500.
+ var state = BuildAudioState("pcm_s16le", 48000, "wav");
+ state.BaseRequest.AudioBitRate = null;
+ var args = CreateHelper().GetProgressiveAudioFullCommandLine(state, new EncodingOptions(), "/tmp/out");
+
+ Assert.DoesNotContain("-ar -", args, StringComparison.Ordinal);
+ Assert.DoesNotContain("-ar ", args, StringComparison.Ordinal);
+ Assert.Contains("-ar 48000", args, StringComparison.Ordinal);
+ }
+
+ private static EncodingJobInfo BuildAudioState(string audioCodec, int requestedSampleRate, string? outputContainer = null)
{
var audio = new MediaStream { Index = 0, Type = MediaStreamType.Audio, Codec = "flac", SampleRate = 96000 };
return new EncodingJobInfo(TranscodingJobType.Progressive)
{
+ OutputContainer = outputContainer,
MediaSource = new MediaSourceInfo
{
Container = "flac",
diff --git a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs b/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
deleted file mode 100644
index f44cb88834..0000000000
--- a/tests/Jellyfin.LiveTv.Tests/LiveTvChannelImageHelperTests.cs
+++ /dev/null
@@ -1,51 +0,0 @@
-using Jellyfin.LiveTv;
-using MediaBrowser.Controller.Entities;
-using MediaBrowser.Controller.LiveTv;
-using MediaBrowser.Model.Entities;
-using Xunit;
-
-namespace Jellyfin.LiveTv.Tests;
-
-public class LiveTvChannelImageHelperTests
-{
- [Fact]
- public void UpdateChannelImageIfNeeded_NoSource_DoesNotUpdate()
- {
- var channel = new LiveTvChannel { Name = "Test Channel" };
-
- var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, null);
-
- Assert.False(updated);
- Assert.False(channel.HasImage(ImageType.Primary));
- }
-
- [Fact]
- public void UpdateChannelImageIfNeeded_WithUrl_AppliesUrl()
- {
- var channel = new LiveTvChannel { Name = "Test Channel" };
-
- var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
- channel,
- null,
- "https://example.com/icon.png");
-
- Assert.True(updated);
- Assert.True(channel.HasImage(ImageType.Primary));
- Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
- }
-
- [Fact]
- public void UpdateChannelImageIfNeeded_SameUrl_StillUpdates()
- {
- var channel = new LiveTvChannel { Name = "Test Channel" };
- LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(channel, null, "https://example.com/icon.png");
-
- var updated = LiveTvChannelImageHelper.UpdateChannelImageIfNeeded(
- channel,
- null,
- "https://example.com/icon.png");
-
- Assert.True(updated);
- Assert.Equal("https://example.com/icon.png", channel.GetImagePath(ImageType.Primary));
- }
-}
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 5ba061296a..f5a023686c 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -371,6 +371,45 @@ namespace Jellyfin.Model.Tests
Assert.Equal(streamInfo?.SubtitleStreamIndex, options.SubtitleStreamIndex);
}
+ [Theory]
+ [InlineData("pgssub", null)]
+ [InlineData("vobsub", "mks")]
+ public async Task BuildVideoItemWithSecondaryAudioAndExternalGraphicalSubtitleKeepsVideoCopy(string subtitleCodec, string? subtitleContainer)
+ {
+ var options = await GetMediaOptions("Chrome", "mp4-h264-ac3-aac-srt-2600k");
+ var subtitleStream = options.MediaSources[0].MediaStreams[^1];
+ subtitleStream.Codec = subtitleCodec;
+ subtitleStream.IsExternal = false;
+ subtitleStream.SupportsExternalStream = true;
+ subtitleStream.Path = null;
+
+ options.Profile.SubtitleProfiles =
+ [
+ new SubtitleProfile
+ {
+ Format = subtitleCodec,
+ Container = subtitleContainer,
+ Method = SubtitleDeliveryMethod.External
+ }
+ ];
+ options.AudioStreamIndex = 2;
+ options.SubtitleStreamIndex = subtitleStream.Index;
+
+ var streamInfo = GetStreamBuilder(enableSubtitleExtraction: false).GetOptimalVideoStream(options);
+
+ Assert.NotNull(streamInfo);
+ Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
+ Assert.Equal(TranscodeReason.SecondaryAudioNotSupported, streamInfo.TranscodeReasons);
+ Assert.Equal(SubtitleDeliveryMethod.External, streamInfo.SubtitleDeliveryMethod);
+ Assert.Contains("h264", streamInfo.VideoCodecs);
+ Assert.Contains("aac", streamInfo.AudioCodecs);
+
+ var queryString = streamInfo.ToUrl("media:", "ACCESSTOKEN", null).Split('?', 2).ElementAtOrDefault(1);
+ var query = System.Web.HttpUtility.ParseQueryString(queryString ?? string.Empty);
+ Assert.Null(query["SubtitleStreamIndex"]);
+ Assert.Null(query["SubtitleMethod"]);
+ }
+
private StreamInfo? BuildVideoItemSimpleTest(MediaOptions options, PlayMethod? playMethod, TranscodeReason why, string transcodeMode, string transcodeProtocol)
{
if (string.IsNullOrEmpty(transcodeProtocol))
@@ -573,9 +612,10 @@ namespace Jellyfin.Model.Tests
throw new SerializationException("Invalid test data: " + name);
}
- private StreamBuilder GetStreamBuilder()
+ private StreamBuilder GetStreamBuilder(bool enableSubtitleExtraction = false)
{
var transcodeSupport = new Mock<ITranscoderSupport>();
+ transcodeSupport.Setup(t => t.CanExtractSubtitles(It.IsAny<string>())).Returns(enableSubtitleExtraction);
var logger = new NullLogger<StreamBuilderTests>();
return new StreamBuilder(transcodeSupport.Object, logger);
@@ -625,7 +665,7 @@ namespace Jellyfin.Model.Tests
// EnableSubtitleExtraction = false, internal subtitles
[InlineData("srt", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)]
[InlineData("srt", "srt", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)]
- [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)]
+ [InlineData("pgssub", "pgssub", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.External)]
[InlineData("pgssub", "pgssub", false, false, PlayMethod.DirectPlay, SubtitleDeliveryMethod.External)]
[InlineData("pgssub", "srt", false, false, PlayMethod.Transcode, SubtitleDeliveryMethod.Encode)]
// EnableSubtitleExtraction = false, external subtitles
@@ -678,7 +718,7 @@ namespace Jellyfin.Model.Tests
[Theory]
[InlineData(false, null, true, SubtitleDeliveryMethod.External)]
- [InlineData(false, null, false, SubtitleDeliveryMethod.Encode)]
+ [InlineData(false, null, false, SubtitleDeliveryMethod.External)]
[InlineData(true, "/media/sub.mks", true, SubtitleDeliveryMethod.External)]
[InlineData(true, "/media/sub.idx", true, SubtitleDeliveryMethod.Encode)]
[InlineData(true, "/media/sub.sub", true, SubtitleDeliveryMethod.Encode)]
diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs
index d42bd66a1c..0e35071dd4 100644
--- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs
+++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs
@@ -20,6 +20,27 @@ namespace Jellyfin.Naming.Tests.Video
}
[Fact]
+ public void TestFormat3DAtEndOfPath()
+ {
+ // Directory based media (eg. DVD or BluRay folder rips) have no file extension,
+ // so the 3D tag can be the last token of the path.
+ Test("Super movie (2009) 3d hsbs", true, "hsbs");
+ Test("Super movie (2009).3d.sbs", true, "sbs");
+ Test("Super movie (2009) 3d htab", true, "htab");
+ Test("Super movie (2009).hsbs", true, "hsbs");
+ Test("Super movie (2009) 3d", false, null);
+ }
+
+ [Fact]
+ public void TestResolveDirectory3D()
+ {
+ var result = VideoResolver.ResolveDirectory("/movies/Oblivion (2013) 3d hsbs", _namingOptions);
+
+ Assert.True(result?.Is3D);
+ Assert.Equal("hsbs", result?.Format3D, true);
+ }
+
+ [Fact]
public void Test3DName()
{
var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions);
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index 1f523f7f21..d8cb9e1ac6 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -6,6 +6,7 @@ using Jellyfin.Networking.Manager;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Net;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -493,5 +494,219 @@ namespace Jellyfin.Networking.Tests
Assert.Equal(result, interfaceToUse);
}
+
+ [Theory]
+ // Internal override with an explicit port.
+ [InlineData("192.168.1.1", "192.168.1.0/24=internal.jellyfin:8097", "internal.jellyfin", 8097)]
+ // External/all override with an explicit port.
+ [InlineData("8.8.8.8", "all=external.jellyfin:8097", "external.jellyfin", 8097)]
+ // Bracketed IPv6 override with an explicit port.
+ [InlineData("8.8.8.8", "all=[fd00:1234::1]:8097", "fd00:1234::1", 8097)]
+ // Bare IPv6 override without a port - must remain whole, not mangled by the extra colons.
+ [InlineData("8.8.8.8", "all=fd00:1234::1", "fd00:1234::1", null)]
+ // Full HTTPS URL override with an explicit port - the URL stays whole, port stays embedded.
+ [InlineData("8.8.8.8", "all=https://secure.jellyfin.org:8920", "https://secure.jellyfin.org:8920", null)]
+ // Hostname beginning with "http" is a hostname, not a URL scheme.
+ [InlineData("8.8.8.8", "all=http-proxy.lan:8097", "http-proxy.lan", 8097)]
+ // Literal "internal" keyword override (applies to every LAN subnet) with an explicit port.
+ [InlineData("192.168.1.1", "internal=myhost.internal:8097", "myhost.internal", 8097)]
+ // Literal "external" keyword override with an explicit port.
+ [InlineData("8.8.8.8", "external=myhost.external:9090", "myhost.external", 9090)]
+ public void GetBindAddress_PublishedServerOverride_ParsesHostAndPort(string source, string publishedServers, string expectedHost, int? expectedPort)
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16", "eth11" },
+ EnableIPv4 = true,
+ PublishedServerUriBySubnet = new[] { publishedServers }
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11";
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ var intf = nm.GetBindAddress(IPAddress.Parse(source), out int? port);
+
+ Assert.Equal(expectedHost, intf);
+ Assert.Equal(expectedPort, port);
+ }
+
+ /// <summary>
+ /// Regression coverage for <c>IServerApplicationHost.GetApiUrlForLocalAccess()</c>, which calls
+ /// <see cref="NetworkManager.GetBindAddress(IPAddress, out int?, bool)"/> with a null source address.
+ /// Published server URL overrides are only matched when a source address is supplied
+ /// (<c>MatchesPublishedServerUrl</c> requires it), so a null source must never come back as a published
+ /// CLI/dashboard URL - it must fall back to a plain local bind address.
+ /// </summary>
+ [Fact]
+ public void GetBindAddress_NullSource_DoesNotApplyPublishedServerOverride()
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16", "eth11" },
+ EnableIPv4 = true,
+ PublishedServerUriBySubnet = new[] { "all=http://published.example.com" }
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11";
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ var result = nm.GetBindAddress((IPAddress?)null, out var port);
+
+ Assert.Equal("192.168.1.208", result);
+ Assert.Null(port);
+ }
+
+ [Theory]
+ // Full-URL override with a different public path: warn about the Live TV fallback.
+ [InlineData("all=https://media.example.com", "/jellyfin", true)]
+ // Full-URL override that ends with the base URL (with and without a trailing slash): no warning.
+ [InlineData("all=https://media.example.com/jellyfin", "/jellyfin", false)]
+ [InlineData("all=https://media.example.com/jellyfin/", "/jellyfin", false)]
+ [InlineData("all=https://media.example.com/media/jellyfin", "/jellyfin", false)]
+ [InlineData("all=https://media.example.com/cool%20server", "/cool server", false)]
+ // A similar segment or a path following the base URL is a different public API base.
+ [InlineData("all=https://media.example.com/jellyfinx", "/jellyfin", true)]
+ [InlineData("all=https://media.example.com/jellyfin/media", "/jellyfin", true)]
+ // No base URL configured: there is no path to compare.
+ [InlineData("all=https://media.example.com", "", false)]
+ // Bare host overrides get the base URL appended when the API URL is built: no warning.
+ [InlineData("all=media.example.com", "/jellyfin", false)]
+ [InlineData("internal=http-proxy.lan:8097", "/jellyfin", false)]
+ // Keyword overrides go through the same check as "all".
+ [InlineData("internal=http://10.0.0.5:8096", "/jellyfin", true)]
+ public void InitializeOverrides_FullUrlPublicPathDiffersFromBaseUrl_LogsWarning(string publishedServers, string baseUrl, bool expectWarning)
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16" },
+ EnableIPv4 = true,
+ PublishedServerUriBySubnet = new[] { publishedServers },
+ BaseUrl = baseUrl
+ };
+
+ var logger = new Mock<ILogger<NetworkManager>>();
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16";
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object);
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ VerifyBaseUrlWarning(logger, expectWarning ? Times.AtLeastOnce() : Times.Never());
+ }
+
+ /// <summary>
+ /// The JELLYFIN_PublishedServerUrl environment variable / --published-server-url option takes the
+ /// startup-configuration branch of <c>InitializeOverrides</c> and must funnel through the same
+ /// base URL check as the dashboard overrides.
+ /// </summary>
+ [Fact]
+ public void InitializeOverrides_StartupPublishedServerUrlPathDiffersFromBaseUrl_LogsWarningWithoutCredentials()
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16" },
+ EnableIPv4 = true,
+ BaseUrl = "/jellyfin"
+ };
+
+ var logger = new Mock<ILogger<NetworkManager>>();
+ var startupConf = new Mock<IConfiguration>();
+ startupConf.Setup(x => x[MediaBrowser.Controller.Extensions.ConfigurationExtensions.AddressOverrideKey]).Returns("https://user:password@media.example.com?access_token=secret#fragment");
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16";
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, logger.Object);
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ VerifyBaseUrlWarning(logger, Times.AtLeastOnce());
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("user", StringComparison.Ordinal)
+ || state.ToString()!.Contains("password", StringComparison.Ordinal)
+ || state.ToString()!.Contains("access_token", StringComparison.Ordinal)
+ || state.ToString()!.Contains("secret", StringComparison.Ordinal)
+ || state.ToString()!.Contains("fragment", StringComparison.Ordinal)),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Never());
+ }
+
+ private static void VerifyBaseUrlWarning(Mock<ILogger<NetworkManager>> logger, Times times)
+ {
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("Jellyfin will append this base URL when generating Live TV client URLs", StringComparison.Ordinal)),
+ It.IsAny<Exception?>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ times);
+ }
+
+ /// <summary>
+ /// <see cref="NetworkManager.GetBindAddress(HttpRequest, out int?)"/> is the piece of request-host
+ /// normalization that a request-host-aware smart API URL policy relies on: it resolves the bind address
+ /// from the request's host and falls back to the request's own port when no override applies.
+ /// </summary>
+ [Fact]
+ public void GetBindAddress_HttpRequestOverload_FallsBackToRequestPortWhenNoOverride()
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16", "eth11" },
+ EnableIPv4 = true
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11";
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Host = new HostString("192.168.1.1", 34567);
+
+ var result = nm.GetBindAddress(httpContext.Request, out var port);
+
+ Assert.Equal("192.168.1.208", result);
+ Assert.Equal(34567, port);
+ }
+
+ /// <summary>
+ /// Ordering check: a dashboard published-server-URL override's explicit port takes precedence over the
+ /// request's own port, even though the request's host chose which override subnet matched.
+ /// </summary>
+ [Fact]
+ public void GetBindAddress_HttpRequestOverload_PublishedOverridePortWinsOverRequestPort()
+ {
+ var conf = new NetworkConfiguration
+ {
+ LocalNetworkSubnets = new[] { "192.168.1.0/24" },
+ LocalNetworkAddresses = new[] { "eth16", "eth11" },
+ EnableIPv4 = true,
+ PublishedServerUriBySubnet = new[] { "internal=myhost.internal:9000" }
+ };
+
+ NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11";
+ var startupConf = new Mock<IConfiguration>();
+ using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>());
+ NetworkManager.MockNetworkSettings = string.Empty;
+
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Host = new HostString("192.168.1.1", 34567);
+
+ var result = nm.GetBindAddress(httpContext.Request, out var port);
+
+ Assert.Equal("myhost.internal", result);
+ Assert.Equal(9000, port);
+ }
}
}
diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs
new file mode 100644
index 0000000000..7813013c05
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbMissingEpisodeProviderTests.cs
@@ -0,0 +1,275 @@
+using System;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.Tmdb.TV;
+using TMDbLib.Objects.Search;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.Tmdb;
+
+public class TmdbMissingEpisodeProviderTests
+{
+ private static readonly DateTime _today = new(2026, 6, 20, 0, 0, 0, DateTimeKind.Utc);
+
+ [Theory]
+ // No air date -> never imported, regardless of options.
+ [InlineData(null, true, true, false, false, false)]
+ [InlineData(null, false, false, false, false, false)]
+ // Future (unaired) episodes are gated by the unaired option.
+ [InlineData(5, true, false, false, false, true)]
+ [InlineData(5, false, false, false, false, false)]
+ [InlineData(5, false, true, false, false, false)]
+ // Today counts as unaired.
+ [InlineData(0, true, false, false, false, true)]
+ [InlineData(0, false, false, false, false, false)]
+ // Past (already aired) episodes are gated by the missing option.
+ [InlineData(-5, false, true, false, false, true)]
+ [InlineData(-5, false, false, false, false, false)]
+ [InlineData(-5, true, false, false, false, false)]
+ // Specials are never imported when the specials option is off, regardless of air date.
+ [InlineData(5, true, false, true, false, false)]
+ [InlineData(-5, false, true, true, false, false)]
+ // Specials follow the normal air-date gating when the specials option is on.
+ [InlineData(5, true, false, true, true, true)]
+ [InlineData(5, false, false, true, true, false)]
+ [InlineData(-5, false, true, true, true, true)]
+ [InlineData(-5, false, false, true, true, false)]
+ public void ShouldImportEpisode_RespectsAirDateAndOptions(int? dayOffset, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials, bool expected)
+ {
+ DateTime? premiere = dayOffset.HasValue ? _today.AddDays(dayOffset.Value) : null;
+
+ Assert.Equal(expected, TmdbMissingEpisodeProvider.ShouldImportEpisode(premiere, _today, importUnaired, importMissing, isSpecial, importSpecials));
+ }
+
+ [Fact]
+ public void ShouldPrune_AgedOutVirtualTmdbEpisode_ReturnsTrue()
+ {
+ var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true);
+
+ Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_NotInPruningMode_ReturnsFalse()
+ {
+ var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: true);
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 0, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_StillUpcoming_ReturnsFalse()
+ {
+ var episode = VirtualEpisode(_today.AddDays(1), withTmdbId: true);
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_VirtualEpisodeFromAnotherProvider_ReturnsFalse()
+ {
+ // No TMDb id -> not created by this provider (e.g. a TheTVDB plugin entry) -> left untouched.
+ var episode = VirtualEpisode(_today.AddDays(-1), withTmdbId: false);
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_PhysicalEpisode_ReturnsFalse()
+ {
+ var episode = new Episode { Path = "/media/show/Season 01/s01e01.mkv", PremiereDate = _today.AddDays(-1) };
+ episode.SetProviderId(MetadataProvider.Tmdb, "123");
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 0, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_AiredWithinGracePeriod_ReturnsFalse()
+ {
+ // Aired two days ago but the grace period keeps it around for the file to be added.
+ var episode = VirtualEpisode(_today.AddDays(-2), withTmdbId: true);
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_AiredBeyondGracePeriod_ReturnsTrue()
+ {
+ var episode = VirtualEpisode(_today.AddDays(-10), withTmdbId: true);
+
+ Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true));
+ }
+
+ [Fact]
+ public void ShouldPrune_SpecialWithSpecialsDisabled_ReturnsTrue()
+ {
+ // Specials are removed entirely when the specials option is off, even when not in pruning mode.
+ var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0);
+
+ Assert.True(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: false, _today, gracePeriodDays: 7, importSpecials: false));
+ }
+
+ [Fact]
+ public void ShouldPrune_SpecialWithSpecialsEnabled_FollowsNormalRules()
+ {
+ // With specials enabled, an upcoming special is kept like any other upcoming episode.
+ var episode = VirtualEpisode(_today.AddDays(5), withTmdbId: true, seasonNumber: 0);
+
+ Assert.False(TmdbMissingEpisodeProvider.ShouldPrune(episode, pruneAgedOut: true, _today, gracePeriodDays: 7, importSpecials: true));
+ }
+
+ [Fact]
+ public void GetPremiereDate_NullAirDate_ReturnsNull()
+ {
+ Assert.Null(TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = null }));
+ }
+
+ [Fact]
+ public void GetPremiereDate_AirDate_ReturnsUtc()
+ {
+ var airDate = new DateTime(2026, 7, 28);
+
+ var result = TmdbMissingEpisodeProvider.GetPremiereDate(new TvSeasonEpisode { AirDate = airDate });
+
+ Assert.NotNull(result);
+ Assert.Equal(DateTimeKind.Utc, result!.Value.Kind);
+ Assert.Equal(DateTime.SpecifyKind(airDate, DateTimeKind.Local).ToUniversalTime(), result.Value);
+ }
+
+ [Fact]
+ public void UpdateVirtualEpisode_PlaceholderTitleReplaced_UpdatesAndReturnsTrue()
+ {
+ var episode = new Episode { Name = "Episode 14" };
+ var tmdbEpisode = new TvSeasonEpisode { Name = "The Real Title" };
+
+ Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null));
+ Assert.Equal("The Real Title", episode.Name);
+ }
+
+ [Fact]
+ public void UpdateVirtualEpisode_NoChanges_ReturnsFalse()
+ {
+ var date = _today;
+ var episode = new Episode { Name = "Same", Overview = "Description", PremiereDate = date };
+ var tmdbEpisode = new TvSeasonEpisode { Name = "Same", Overview = "Description" };
+
+ Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, date));
+ }
+
+ [Fact]
+ public void UpdateVirtualEpisode_EmptyTmdbValues_DoNotOverwrite()
+ {
+ var episode = new Episode { Name = "Existing", Overview = "Existing overview" };
+ var tmdbEpisode = new TvSeasonEpisode { Name = string.Empty, Overview = null };
+
+ Assert.False(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, null));
+ Assert.Equal("Existing", episode.Name);
+ Assert.Equal("Existing overview", episode.Overview);
+ }
+
+ [Fact]
+ public void UpdateVirtualEpisode_RescheduledAirDate_UpdatesPremiereAndYear()
+ {
+ var episode = new Episode { Name = "X", PremiereDate = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc) };
+ var newAirDate = new DateTime(2026, 8, 15);
+ var newPremiere = DateTime.SpecifyKind(newAirDate, DateTimeKind.Local).ToUniversalTime();
+ var tmdbEpisode = new TvSeasonEpisode { Name = "X", AirDate = newAirDate };
+
+ Assert.True(TmdbMissingEpisodeProvider.UpdateVirtualEpisode(episode, tmdbEpisode, newPremiere));
+ Assert.Equal(newPremiere, episode.PremiereDate);
+ Assert.Equal(2026, episode.ProductionYear);
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_NoNameSortedPhysicalSeason_ReturnsNull()
+ {
+ // No physical season carries a forced (name-based) sort name -> virtual seasons keep their
+ // bare-index sort, so no template is produced.
+ var seasons = new[]
+ {
+ PhysicalSeason(1, forcedSortName: null),
+ VirtualSeason(3),
+ };
+
+ Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(seasons));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_MirrorsSiblingConventionAndSwapsNumber()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Season 01"),
+ VirtualSeason(3),
+ });
+
+ Assert.NotNull(template);
+ // Keeps the sibling's text token and zero-padding width, swapping in the target number.
+ Assert.Equal("Season 03", template!(3));
+ Assert.Equal("Season 12", template(12));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_PreservesNonEnglishToken()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Staffel 1"),
+ VirtualSeason(2),
+ });
+
+ Assert.NotNull(template);
+ Assert.Equal("Staffel 2", template!(2));
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_SiblingWithoutDigits_ReturnsNull()
+ {
+ var template = TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: "Miniseries"),
+ VirtualSeason(2),
+ });
+
+ Assert.Null(template);
+ }
+
+ [Fact]
+ public void BuildSeasonSortNameTemplate_IgnoresVirtualSeasonsAsReference()
+ {
+ // A virtual season's own forced sort name must not be used as the convention source.
+ var virtualWithForced = VirtualSeason(3);
+ virtualWithForced.ForcedSortName = "Season 03";
+
+ Assert.Null(TmdbMissingEpisodeProvider.BuildSeasonSortNameTemplate(new[]
+ {
+ PhysicalSeason(1, forcedSortName: null),
+ virtualWithForced,
+ }));
+ }
+
+ private static Season PhysicalSeason(int indexNumber, string? forcedSortName)
+ {
+ var season = new Season { IndexNumber = indexNumber, Path = $"/media/show/Season {indexNumber:00}" };
+ if (!string.IsNullOrEmpty(forcedSortName))
+ {
+ season.ForcedSortName = forcedSortName;
+ }
+
+ return season;
+ }
+
+ private static Season VirtualSeason(int indexNumber)
+ => new Season { IndexNumber = indexNumber, IsVirtualItem = true };
+
+ private static Episode VirtualEpisode(DateTime premiereDate, bool withTmdbId, int? seasonNumber = null)
+ {
+ var episode = new Episode { PremiereDate = premiereDate, IsVirtualItem = true, ParentIndexNumber = seasonNumber };
+ if (withTmdbId)
+ {
+ episode.SetProviderId(MetadataProvider.Tmdb, "123");
+ }
+
+ return episode;
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
index 6b6240e116..d18f8c6cff 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceImageInheritanceTests.cs
@@ -14,6 +14,7 @@ using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
using Moq;
using Xunit;
@@ -155,6 +156,55 @@ public class DtoServiceImageInheritanceTests
libraryManager.Verify(x => x.GetArtist(It.IsAny<string>(), It.IsAny<DtoOptions>()), Times.Never);
}
+ [Fact]
+ public void GetBaseItemDtos_Items_ResolvePeopleFromBatch_WithoutPerItemLookup()
+ {
+ static MusicAlbum MakeAlbum() => new MusicAlbum
+ {
+ Id = Guid.NewGuid(),
+ Name = "Album",
+ ImageInfos = []
+ };
+
+ var albumOne = MakeAlbum();
+ var albumTwo = MakeAlbum();
+
+ var libraryManager = new Mock<ILibraryManager>();
+
+ // DtoService resolves people for every item in ONE batch (GetPeopleByItems) before the
+ // per-item loop. A regression to the per-item path would call GetPeople(BaseItem) once per
+ // item (the N+1); it is intentionally left unset so such a regression fails here.
+ libraryManager
+ .Setup(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()))
+ .Returns(new Dictionary<Guid, IReadOnlyList<PersonInfo>>
+ {
+ [albumOne.Id] = [new PersonInfo { ItemId = albumOne.Id, Name = "Some Actor", Type = PersonKind.Actor }],
+ [albumTwo.Id] = [new PersonInfo { ItemId = albumTwo.Id, Name = "Some Actor", Type = PersonKind.Actor }]
+ });
+
+ // AttachPeople still resolves each distinct name to its Person entity to attach images.
+ libraryManager
+ .Setup(x => x.GetPerson("Some Actor"))
+ .Returns(new Person { Id = Guid.NewGuid(), Name = "Some Actor" });
+
+ var dtoService = BuildDtoService(libraryManager);
+
+ var options = new DtoOptions(false) { Fields = [ItemFields.People] };
+ var dtos = dtoService.GetBaseItemDtos([albumOne, albumTwo], options);
+
+ Assert.Equal(2, dtos.Count);
+ foreach (var dto in dtos)
+ {
+ Assert.NotNull(dto.People);
+ Assert.Single(dto.People);
+ Assert.Equal("Some Actor", dto.People[0].Name);
+ }
+
+ // People are batched once for the whole set, never once per item.
+ libraryManager.Verify(x => x.GetPeopleByItems(It.IsAny<IReadOnlyList<Guid>>()), Times.Once);
+ libraryManager.Verify(x => x.GetPeople(It.IsAny<BaseItem>()), Times.Never);
+ }
+
private static DtoService BuildDtoService(BaseItem displayParent)
{
var libraryManager = new Mock<ILibraryManager>();
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
new file mode 100644
index 0000000000..f675621e21
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Linq;
+using Emby.Server.Implementations.Data;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Entities;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Model.Configuration;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+/// <summary>
+/// The by-name endpoints (artists, album artists, genres, studios) all funnel through
+/// <c>GetItemValues</c>. A query without a <c>Limit</c> used to have its total record count
+/// silently disabled, so callers got a populated <c>Items</c> array next to a zero total.
+/// </summary>
+public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly BaseItemRepository _repository;
+ private readonly ItemTypeLookup _itemTypeLookup;
+
+ public BaseItemRepositoryByNameTotalCountTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ _itemTypeLookup = new ItemTypeLookup();
+
+ var serverConfigurationManager = new Mock<IServerConfigurationManager>();
+ serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+
+ _repository = new BaseItemRepository(
+ factory.Object,
+ new Mock<IServerApplicationHost>().Object,
+ _itemTypeLookup,
+ serverConfigurationManager.Object,
+ NullLogger<BaseItemRepository>.Instance);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_ReportsTotalRecordCount()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: null));
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithLimit_ReportsTotalBeyondThePage()
+ {
+ SeedArtists(3);
+
+ var result = _repository.GetArtists(CreateQuery(limit: 2));
+
+ Assert.Equal(2, result.Items.Count);
+ Assert.Equal(3, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_TotalRecordCountDisabled_StaysZero()
+ {
+ SeedArtists(3);
+
+ var query = CreateQuery(limit: null);
+ query.EnableTotalRecordCount = false;
+
+ var result = _repository.GetArtists(query);
+
+ Assert.Equal(3, result.Items.Count);
+ Assert.Equal(0, result.TotalRecordCount);
+ }
+
+ [Fact]
+ public void GetArtists_WithoutLimit_DoesNotMutateCallerQuery()
+ {
+ SeedArtists(1);
+
+ var query = CreateQuery(limit: null);
+ Assert.True(query.EnableTotalRecordCount);
+
+ _repository.GetArtists(query);
+
+ // The repository used to flip this flag on the caller's own query object, so a
+ // reused query silently lost its total on every subsequent call.
+ Assert.True(query.EnableTotalRecordCount);
+ }
+
+ private static InternalItemsQuery CreateQuery(int? limit)
+ {
+ return new InternalItemsQuery(new User("test", "auth", "reset"))
+ {
+ Limit = limit
+ };
+ }
+
+ /// <summary>
+ /// Creates <paramref name="count"/> artists, each credited on one song, which is what
+ /// makes them visible to the item-value join behind the by-name endpoints.
+ /// </summary>
+ private void SeedArtists(int count)
+ {
+ using var ctx = CreateDbContext();
+
+ for (var i = 0; i < count; i++)
+ {
+ var name = $"Artist {i}";
+ var cleanName = name.ToLowerInvariant();
+
+ var artistId = Guid.Parse($"aaaaaaaa-0000-0000-0000-{i:D12}");
+ var songId = Guid.Parse($"55555555-0000-0000-0000-{i:D12}");
+ var valueId = Guid.Parse($"cccccccc-0000-0000-0000-{i:D12}");
+
+ var artist = new BaseItemEntity
+ {
+ Id = artistId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist],
+ Name = name,
+ CleanName = cleanName,
+ PresentationUniqueKey = artistId.ToString("N"),
+ IsFolder = true,
+ IsVirtualItem = false
+ };
+
+ var song = new BaseItemEntity
+ {
+ Id = songId,
+ Type = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio],
+ Name = $"Song {i}",
+ CleanName = $"song {i}",
+ PresentationUniqueKey = songId.ToString("N"),
+ MediaType = "Audio",
+ IsFolder = false,
+ IsVirtualItem = false
+ };
+
+ var itemValue = new ItemValue
+ {
+ ItemValueId = valueId,
+ Type = ItemValueType.Artist,
+ Value = name,
+ CleanValue = cleanName
+ };
+
+ ctx.BaseItems.Add(artist);
+ ctx.BaseItems.Add(song);
+ ctx.ItemValues.Add(itemValue);
+ ctx.ItemValuesMap.Add(new ItemValueMap
+ {
+ ItemId = songId,
+ ItemValueId = valueId,
+ Item = song,
+ ItemValue = itemValue
+ });
+ }
+
+ ctx.SaveChanges();
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
new file mode 100644
index 0000000000..6324706452
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs
@@ -0,0 +1,149 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using Jellyfin.Database.Implementations;
+using Jellyfin.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class ItemPersistenceOwnedRowTests : IDisposable
+{
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly ItemPersistenceService _service;
+ private readonly IApplicationPaths _applicationPaths;
+ private readonly ILibraryManager? _previousLibraryManager;
+ private readonly IServerConfigurationManager? _previousConfigurationManager;
+
+ public ItemPersistenceOwnedRowTests()
+ {
+ _applicationPaths = new Mock<IApplicationPaths>().Object;
+
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ }
+
+ // BaseItem resolves these through process-wide statics; restored in Dispose.
+ _previousLibraryManager = BaseItem.LibraryManager;
+ _previousConfigurationManager = BaseItem.ConfigurationManager;
+
+ var libraryManager = new Mock<ILibraryManager>();
+ libraryManager.Setup(l => l.GetCollectionFolders(It.IsAny<BaseItem>()))
+ .Returns([]);
+ BaseItem.LibraryManager = libraryManager.Object;
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
+ BaseItem.ConfigurationManager = configurationManager.Object;
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ _service = new ItemPersistenceService(
+ factory.Object,
+ new Mock<IServerApplicationHost>().Object,
+ NullLogger<ItemPersistenceService>.Instance);
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager!;
+ BaseItem.ConfigurationManager = _previousConfigurationManager!;
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void SaveItems_UpdateExistingItem_ReplacesOwnedRows()
+ {
+ var id = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt0001", ["Tmdb"] = "555" }, [MetadataField.Name])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ Assert.Equal(2, ctx.BaseItemProviders.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(1, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+
+ // Re-save with different owned rows: the update path rewrites all three tables wholesale.
+ _service.SaveItems(
+ [CreateBook(id, new() { ["Imdb"] = "tt9999" }, [MetadataField.Name, MetadataField.Genres])],
+ CancellationToken.None);
+
+ using (var ctx = CreateDbContext())
+ {
+ var providers = ctx.BaseItemProviders.Where(e => e.ItemId.Equals(id)).ToList();
+ Assert.Equal("tt9999", Assert.Single(providers).ProviderValue);
+
+ Assert.Equal(1, ctx.BaseItemImageInfos.Count(e => e.ItemId.Equals(id)));
+ Assert.Equal(2, ctx.BaseItemMetadataFields.Count(e => e.ItemId.Equals(id)));
+ }
+ }
+
+ [Fact]
+ public void SaveItems_MixedNewAndExistingBatch_ReplacesOnlyExistingOwnedRows()
+ {
+ var existing = Guid.Parse("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
+ var fresh = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
+
+ _service.SaveItems([CreateBook(existing, new() { ["Imdb"] = "tt0001" }, [])], CancellationToken.None);
+
+ // One already-persisted item and one brand new item in the same batch.
+ _service.SaveItems(
+ [
+ CreateBook(existing, new() { ["Imdb"] = "tt0002" }, []),
+ CreateBook(fresh, new() { ["Tmdb"] = "777" }, [])
+ ],
+ CancellationToken.None);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal("tt0002", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(existing))).ProviderValue);
+ Assert.Equal("777", Assert.Single(ctx.BaseItemProviders.Where(e => e.ItemId.Equals(fresh))).ProviderValue);
+ }
+
+ private static Book CreateBook(Guid id, Dictionary<string, string> providerIds, MetadataField[] lockedFields)
+ {
+ var book = new Book
+ {
+ Id = id,
+ Name = "Book",
+ ProviderIds = providerIds,
+ LockedFields = lockedFields
+ };
+
+ book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0);
+ return book;
+ }
+
+ private JellyfinDbContext CreateDbContext() => new(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(_applicationPaths, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
new file mode 100644
index 0000000000..70d8e1f833
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs
@@ -0,0 +1,186 @@
+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.Database.Implementations.Locking;
+using Jellyfin.Database.Providers.Sqlite;
+using Jellyfin.Server.Implementations.Item;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Persistence;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
+
+namespace Jellyfin.Server.Implementations.Tests.Item;
+
+public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable
+{
+ private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
+
+ private readonly SqliteConnection _connection;
+ private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
+ private readonly PeopleRepository _repository;
+
+ public PeopleRepositoryUpdatePeopleTests()
+ {
+ _connection = new SqliteConnection("Data Source=:memory:");
+ _connection.Open();
+
+ _dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
+ .UseSqlite(_connection)
+ .Options;
+
+ var itemTypeLookup = new ItemTypeLookup();
+
+ using (var ctx = CreateDbContext())
+ {
+ ctx.Database.EnsureCreated();
+ ctx.BaseItems.Add(new BaseItemEntity
+ {
+ Id = _itemId,
+ Type = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie],
+ Name = "Movie",
+ MediaType = "Video",
+ IsMovie = true,
+ IsFolder = false,
+ IsVirtualItem = false
+ });
+ ctx.SaveChanges();
+ }
+
+ var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
+ factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
+
+ _repository = new PeopleRepository(
+ factory.Object,
+ itemTypeLookup,
+ new Mock<IItemQueryHelpers>().Object);
+ }
+
+ public void Dispose()
+ {
+ _connection.Dispose();
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ Assert.Equal(
+ ["Novel", "Screenplay"],
+ ctx.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditsDifferingOnlyInCase_AreDeduped()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("person a", PersonKind.Actor, "hero")
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Single(ctx.Peoples);
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_SamePersonAsDifferentTypes_CreatesOnePersonPerType()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person A", PersonKind.Director, string.Empty)
+ ]);
+
+ using var ctx = CreateDbContext();
+ Assert.Equal(2, ctx.Peoples.Count());
+ Assert.Equal(2, ctx.PeopleBaseItemMap.Count());
+ }
+
+ [Fact]
+ public void UpdatePeople_RepeatedUpdate_ReusesMappingsAndRefreshesOrder()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Actor, "Hero"),
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick")
+ ]);
+
+ Guid[] peopleIdsBefore;
+ using (var ctx = CreateDbContext())
+ {
+ peopleIdsBefore = ctx.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray();
+ }
+
+ // Reversed order, so the list order of both mappings has to be rewritten.
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person B", PersonKind.Actor, "Sidekick"),
+ CreatePerson("Person A", PersonKind.Actor, "Hero")
+ ]);
+
+ using var after = CreateDbContext();
+ Assert.Equal(peopleIdsBefore, after.Peoples.Select(e => e.Id).OrderBy(e => e).ToArray());
+ Assert.Equal(
+ ["Sidekick", "Hero"],
+ after.PeopleBaseItemMap.OrderBy(e => e.ListOrder).Select(e => e.Role ?? string.Empty).ToArray());
+ }
+
+ [Fact]
+ public void UpdatePeople_CreditRemoved_DropsOnlyThatMapping()
+ {
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel"),
+ CreatePerson("Person A", PersonKind.Writer, "Screenplay")
+ ]);
+
+ _repository.UpdatePeople(_itemId, [
+ CreatePerson("Person A", PersonKind.Writer, "Novel")
+ ]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Novel", map.Role);
+ }
+
+ [Fact]
+ public void UpdatePeople_RoleCaseChanged_KeepsExistingMapping()
+ {
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]);
+
+ _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "HERO")]);
+
+ using var ctx = CreateDbContext();
+ var map = Assert.Single(ctx.PeopleBaseItemMap);
+ Assert.Equal("Hero", map.Role);
+ }
+
+ private static PersonInfo CreatePerson(string name, PersonKind type, string role)
+ {
+ return new PersonInfo
+ {
+ Name = name,
+ Type = type,
+ Role = role
+ };
+ }
+
+ private JellyfinDbContext CreateDbContext()
+ {
+ return new JellyfinDbContext(
+ _dbOptions,
+ NullLogger<JellyfinDbContext>.Instance,
+ new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
+ new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
index 07c537aee1..a28c1d6dfb 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.Json;
using AutoFixture;
using AutoFixture.AutoMoq;
using Emby.Naming.Common;
@@ -17,6 +18,7 @@ using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using Moq;
using Xunit;
@@ -38,9 +40,15 @@ public class FindExtrasTests
itemRepository.Setup(i => i.RetrieveItem(It.IsAny<Guid>())).Returns<BaseItem>(null);
_fileSystemMock = fixture.Freeze<Mock<IFileSystem>>();
_fileSystemMock.Setup(f => f.GetFileInfo(It.IsAny<string>())).Returns<string>(path => new FileSystemMetadata { FullName = path });
+
+ var strings = LoadCoreStrings();
+ fixture.Freeze<Mock<ILocalizationManager>>()
+ .Setup(l => l.GetServerLocalizedString(It.IsAny<string>()))
+ .Returns<string>(key => strings.TryGetValue(key, out var value) ? value : key);
+
_libraryManager = fixture.Build<Emby.Server.Implementations.Library.LibraryManager>().Do(s => s.AddParts(
fixture.Create<IEnumerable<IResolverIgnoreRule>>(),
- new List<IItemResolver> { new AudioResolver(fixture.Create<NamingOptions>()) },
+ [new AudioResolver(fixture.Create<NamingOptions>())],
fixture.Create<IEnumerable<IIntroProvider>>(),
fixture.Create<IEnumerable<IBaseItemComparer>>(),
fixture.Create<IEnumerable<ILibraryPostScanTask>>()))
@@ -51,6 +59,16 @@ public class FindExtrasTests
BaseItem.MediaSourceManager ??= fixture.Create<IMediaSourceManager>();
}
+ private static Dictionary<string, string> LoadCoreStrings()
+ {
+ using var stream = typeof(Emby.Server.Implementations.Library.LibraryManager).Assembly
+ .GetManifestResourceStream("Emby.Server.Implementations.Localization.Core.en-US.json")
+ ?? throw new InvalidOperationException("Core localization resource is missing");
+
+ return JsonSerializer.Deserialize<Dictionary<string, string>>(stream)
+ ?? throw new InvalidOperationException("Core localization resource is empty");
+ }
+
[Fact]
public void FindExtras_SeparateMovieFolder_FindsCorrectExtras()
{
@@ -132,60 +150,60 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/trailers/some trailer.mkv",
Name = "some trailer.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/behind the scenes",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/behind the scenes/the making of Up.mkv",
Name = "the making of Up.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/theme-music",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/theme-music/theme2.mp3",
Name = "theme2.mp3",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
_fileSystemMock.Setup(f => f.GetFiles(
"/movies/Up/extras",
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/extras/Honest Trailer.mkv",
Name = "Honest Trailer.mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
@@ -289,15 +307,15 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/trailers/trailer.jpg",
Name = "trailer.jpg",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.ExtraType).ToList();
@@ -320,15 +338,15 @@ public class FindExtrasTests
It.IsAny<string[]>(),
false,
false))
- .Returns(new List<FileSystemMetadata>
- {
+ .Returns(
+ [
new()
{
FullName = "/movies/Up/trailers/Trailer 1 (2013).mkv",
Name = "Trailer 1 (2013).mkv",
IsDirectory = false
}
- }).Verifiable();
+ ]).Verifiable();
var files = paths.Select(p => new FileSystemMetadata
{
@@ -372,4 +390,198 @@ public class FindExtrasTests
Assert.Equal("/series/Dexter/trailer.mkv", extras[0].Path);
Assert.Equal("/series/Dexter/trailers/trailer2.mkv", extras[1].Path);
}
+
+ [Fact]
+ public void FindExtras_SameExtraInSeveralContainers_ReturnsEach()
+ {
+ var owner = new Movie { Name = "Skyscraper", Path = "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC].mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv",
+ "/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ // A container is a separate file that plays on its own, so it is a separate extra
+ Assert.Equal(4, extras.Count);
+ Assert.Equal("Behind The Scenes", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mkv"]);
+ Assert.Equal("Behind The Scenes 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-behindthescenes.mp4"]);
+ Assert.Equal("Trailer", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mkv"]);
+ Assert.Equal("Trailer 2", extras["/movies/Skyscraper (2018)/Skyscraper (2018) - [1080p HEVC]-trailer.mp4"]);
+ }
+
+ [Fact]
+ public void FindExtras_SameExtraInSeveralResolutions_ReturnsEach()
+ {
+ var owner = new Movie { Name = "Dragon 2", Path = "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p].mkv",
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv",
+ "/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal(2, extras.Count);
+ Assert.Equal("Trailer", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [1080p]-trailer.mkv"]);
+ Assert.Equal("Trailer 2", extras["/movies/Dragon 2 (2014)/Dragon 2 (2014) - [2160p]-trailer.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_NumberedExtras_AreKeptApart()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mkv",
+ "/movies/Up (2009)/Up (2009)-trailer2.mp4",
+ "/movies/Up (2009)/Up (2009)-trailer3.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
+
+ Assert.Equal(4, extras.Count);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer.mkv", extras[0].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mkv", extras[1].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer2.mp4", extras[2].Path);
+ Assert.Equal("/movies/Up (2009)/Up (2009)-trailer3.mkv", extras[3].Path);
+
+ // The index in the file name is not the number the extra is given, which counts the
+ // extras of a type as they are found
+ Assert.Equal("Trailer", extras[0].Name);
+ Assert.Equal("Trailer 2", extras[1].Name);
+ Assert.Equal("Trailer 3", extras[2].Name);
+ Assert.Equal("Trailer 4", extras[3].Name);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraWithOwnTitleBesideOwner_KeepsTitle()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up (2009)/Up (2009).mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up (2009)/Up (2009).mkv",
+ "/movies/Up (2009)/Up (2009)-trailer.mkv",
+ "/movies/Up (2009)/Recording the audio-behindthescenes.mkv",
+ "/movies/Up (2009)/Up (2009)-behindthescenes.mkv"
+ };
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ IsDirectory = false
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ Assert.Equal(3, extras.Count);
+ Assert.Equal("Trailer", extras["/movies/Up (2009)/Up (2009)-trailer.mkv"]);
+
+ // A descriptive file name is a real title and survives, and does not consume a number
+ Assert.Equal("Recording the audio", extras["/movies/Up (2009)/Recording the audio-behindthescenes.mkv"]);
+ Assert.Equal("Behind The Scenes", extras["/movies/Up (2009)/Up (2009)-behindthescenes.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_ExtraInOwnFolder_IsNamedAfterItsFile()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up/Up.mkv",
+ "/movies/Up/trailers"
+ };
+
+ _fileSystemMock.Setup(f => f.GetFiles(
+ "/movies/Up/trailers",
+ It.IsAny<string[]>(),
+ false,
+ false))
+ .Returns(
+ [
+ new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Comic-Con Reel.mkv", Name = "Comic-Con Reel.mkv", IsDirectory = false }
+ ]).Verifiable();
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ IsDirectory = !Path.HasExtension(p)
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object))
+ .ToDictionary(e => e.Path, e => e.Name, StringComparer.Ordinal);
+
+ _fileSystemMock.Verify();
+ Assert.Equal(2, extras.Count);
+ Assert.Equal("Teaser", extras["/movies/Up/trailers/Teaser.mkv"]);
+ Assert.Equal("Comic-Con Reel", extras["/movies/Up/trailers/Comic-Con Reel.mkv"]);
+ }
+
+ [Fact]
+ public void FindExtras_DistinctExtrasInSameFolder_AreKeptApart()
+ {
+ var owner = new Movie { Name = "Up", Path = "/movies/Up/Up.mkv" };
+ var paths = new List<string>
+ {
+ "/movies/Up/Up.mkv",
+ "/movies/Up/trailers"
+ };
+
+ _fileSystemMock.Setup(f => f.GetFiles(
+ "/movies/Up/trailers",
+ It.IsAny<string[]>(),
+ false,
+ false))
+ .Returns(
+ [
+ new() { FullName = "/movies/Up/trailers/Teaser.mkv", Name = "Teaser.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Official.mkv", Name = "Official.mkv", IsDirectory = false },
+ new() { FullName = "/movies/Up/trailers/Official.mp4", Name = "Official.mp4", IsDirectory = false }
+ ]).Verifiable();
+
+ var files = paths.Select(p => new FileSystemMetadata
+ {
+ FullName = p,
+ Name = Path.GetFileName(p),
+ IsDirectory = !Path.HasExtension(p)
+ }).ToList();
+
+ var extras = _libraryManager.FindExtras(owner, files, new DirectoryService(_fileSystemMock.Object)).OrderBy(e => e.Path, StringComparer.Ordinal).ToList();
+
+ _fileSystemMock.Verify();
+ Assert.Equal(3, extras.Count);
+ Assert.Equal("/movies/Up/trailers/Official.mkv", extras[0].Path);
+ Assert.Equal("/movies/Up/trailers/Official.mp4", extras[1].Path);
+ Assert.Equal("/movies/Up/trailers/Teaser.mkv", extras[2].Path);
+ }
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
index ede9e61536..265b6a7f43 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs
@@ -293,7 +293,84 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
Assert.Equal(packageInfo.Versions[0].Version, result.Version);
}
- private PackageInfo GenerateTestPackage()
+ [Fact]
+ public async Task DisablePlugin_CatalogRefresh_StaysDisabled()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var pluginDir = CreateTestPlugin(pluginRoot, "Disable Me", PluginStatus.Active);
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+ var plugin = Assert.Single(pluginManager.Plugins);
+
+ pluginManager.DisablePlugin(plugin);
+
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status);
+
+ // The web shows that a restart is required, but the persisted state must not change.
+ Assert.Equal(PluginStatus.Restart, plugin.GetPluginInfo().Status);
+ Assert.Equal(PluginStatus.Disabled, plugin.Manifest.Status);
+ Assert.True(plugin.Manifest.AutoUpdate);
+
+ // Every catalog fetch rewrites the manifests of installed plugins from the in-memory status.
+ var packageInfo = GenerateTestPackage(plugin.Id);
+ await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), pluginDir, plugin.Manifest.Status);
+
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(pluginDir).Manifest.Status);
+ }
+
+ [Fact]
+ public void Constructor_DisabledPluginSortingBeforeEnabledPlugin_IsNotDeleted()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var disabledDir = CreateTestPlugin(pluginRoot, "AAA Disabled", PluginStatus.Disabled);
+ CreateTestPlugin(pluginRoot, "ZZZ Active", PluginStatus.Active);
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+
+ Assert.True(Directory.Exists(disabledDir));
+ Assert.Contains(pluginManager.Plugins, p => string.Equals(p.Name, "AAA Disabled", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public void LoadAssemblies_DisabledPluginWithSupersededVersion_DoesNotRevertToOldVersion()
+ {
+ var pluginRoot = Path.Combine(_tempPath, "plugins");
+ var id = Guid.NewGuid();
+ var oldDir = CreateTestPlugin(pluginRoot, "Two Versions", PluginStatus.Superseded, new Version(1, 0), id);
+ var newDir = CreateTestPlugin(pluginRoot, "Two Versions_2.0", PluginStatus.Disabled, new Version(2, 0), id, "Two Versions");
+
+ var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, pluginRoot, new Version(1, 0));
+
+ Assert.Empty(pluginManager.LoadAssemblies());
+
+ // Neither version may be touched: the old one stays superseded instead of being loaded
+ // as a stand-in for the version the user disabled.
+ Assert.Equal(PluginStatus.Superseded, pluginManager.LoadManifest(oldDir).Manifest.Status);
+ Assert.Equal(PluginStatus.Disabled, pluginManager.LoadManifest(newDir).Manifest.Status);
+ }
+
+ private string CreateTestPlugin(string root, string folderName, PluginStatus status, Version? version = null, Guid? id = null, string? name = null)
+ {
+ var dir = Path.Combine(root, folderName);
+ Directory.CreateDirectory(dir);
+ FileHelper.CreateEmpty(Path.Combine(dir, "some.dll"));
+
+ var manifest = new PluginManifest
+ {
+ Id = id ?? Guid.NewGuid(),
+ Name = name ?? folderName,
+ Status = status,
+ AutoUpdate = true,
+ TargetAbi = "1.0",
+ Version = (version ?? new Version(1, 0)).ToString()
+ };
+
+ File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(manifest, _options));
+
+ return dir;
+ }
+
+ private PackageInfo GenerateTestPackage(Guid? id = null)
{
var fixture = new Fixture();
fixture.Customize<PackageInfo>(c => c.Without(x => x.Versions).Without(x => x.ImageUrl));
@@ -305,6 +382,10 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins
var packageInfo = fixture.Create<PackageInfo>();
packageInfo.Versions = new[] { versionInfo };
+ if (id.HasValue)
+ {
+ packageInfo.Id = id.Value;
+ }
return packageInfo;
}
diff --git a/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs
new file mode 100644
index 0000000000..a1149ac9be
--- /dev/null
+++ b/tests/Jellyfin.Server.Integration.Tests/ImageProcessorTests.cs
@@ -0,0 +1,131 @@
+using System;
+using System.Globalization;
+using System.IO;
+using Jellyfin.Drawing;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Drawing;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Drawing;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Integration.Tests;
+
+public sealed class ImageProcessorTests : IDisposable
+{
+ private const string CacheRoot = "image-cache";
+ private const string OriginalPath = "/media/poster.jpg";
+ private const string NoOverlayCacheKey = "/media/poster.jpg,quality=90,datemodified=638800000000000000,f=Jpg,width=200,height=300,maxwidth=400,maxheight=500,fillwidth=600,fillheight=700,blur=2,b=000000,fl=layer,v=4";
+ private static readonly DateTime _dateModified = new(638800000000000000, DateTimeKind.Utc);
+ private readonly ImageProcessor _imageProcessor;
+
+ public ImageProcessorTests()
+ {
+ var applicationPaths = new Mock<IServerApplicationPaths>();
+ applicationPaths.SetupGet(paths => paths.ImageCachePath).Returns(CacheRoot);
+
+ var configurationManager = new Mock<IServerConfigurationManager>();
+ configurationManager
+ .SetupGet(manager => manager.Configuration)
+ .Returns(new ServerConfiguration { ParallelImageEncodingLimit = 1 });
+
+ _imageProcessor = new ImageProcessor(
+ NullLogger<ImageProcessor>.Instance,
+ applicationPaths.Object,
+ Mock.Of<IFileSystem>(),
+ Mock.Of<IImageEncoder>(),
+ configurationManager.Object);
+ }
+
+ [Fact]
+ public void GetCacheFilePath_DifferentOverlayTypes_ReturnDifferentPaths()
+ {
+ var percentPlayedPath = GetCacheFilePath(percentPlayed: 1);
+ var unwatchedCountPath = GetCacheFilePath(unwatchedCount: 1);
+
+ Assert.NotEqual(percentPlayedPath, unwatchedCountPath);
+ }
+
+ [Fact]
+ public void GetCacheFilePath_DifferentPercentPlayedValues_ReturnDifferentPaths()
+ {
+ var firstPath = GetCacheFilePath(percentPlayed: 12.5);
+ var secondPath = GetCacheFilePath(percentPlayed: 75.5);
+
+ Assert.NotEqual(firstPath, secondPath);
+ }
+
+ [Fact]
+ public void GetCacheFilePath_DifferentUnwatchedCountValues_ReturnDifferentPaths()
+ {
+ var firstPath = GetCacheFilePath(unwatchedCount: 1);
+ var secondPath = GetCacheFilePath(unwatchedCount: 2);
+
+ Assert.NotEqual(firstPath, secondPath);
+ }
+
+ [Fact]
+ public void GetCacheFilePath_DifferentCultures_ReturnSamePath()
+ {
+ var originalCulture = CultureInfo.CurrentCulture;
+
+ try
+ {
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
+ var expectedPath = GetCacheFilePath(percentPlayed: 12.5);
+
+ CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
+ var actualPath = GetCacheFilePath(percentPlayed: 12.5);
+
+ Assert.Equal(expectedPath, actualPath);
+ }
+ finally
+ {
+ CultureInfo.CurrentCulture = originalCulture;
+ }
+ }
+
+ [Fact]
+ public void GetCacheFilePath_NoOverlay_UsesVersionFourWithExistingSerialization()
+ {
+ var expectedPath = _imageProcessor.GetCachePath(
+ Path.Combine(CacheRoot, "resized-images"),
+ NoOverlayCacheKey,
+ ".jpg");
+
+ Assert.Equal(expectedPath, GetCacheFilePath());
+ }
+
+ public void Dispose()
+ {
+ _imageProcessor.Dispose();
+ }
+
+ private string GetCacheFilePath(double percentPlayed = 0, int? unwatchedCount = null)
+ {
+ var options = new ImageProcessingOptions
+ {
+ Width = 200,
+ Height = 300,
+ MaxWidth = 400,
+ MaxHeight = 500,
+ FillWidth = 600,
+ FillHeight = 700,
+ Quality = 90,
+ PercentPlayed = percentPlayed,
+ UnplayedCount = unwatchedCount,
+ Blur = 2,
+ BackgroundColor = "000000",
+ ForegroundLayer = "layer"
+ };
+
+ return _imageProcessor.GetCacheFilePath(
+ OriginalPath,
+ _dateModified,
+ ImageFormat.Jpg,
+ options);
+ }
+}