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.cs64
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs46
-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/Library/LibraryManager/FindExtrasTests.cs250
6 files changed, 1249 insertions, 26 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..2a2da58674 100644
--- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
+++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs
@@ -443,4 +443,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.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.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/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);
+ }
}