aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-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/Item/PeopleRepositoryUpdatePeopleTests.cs186
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/LibraryManager/FindExtrasTests.cs250
10 files changed, 1581 insertions, 78 deletions
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/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);
+ }
}