diff options
| author | WizardOfYendor1 <WizardOfYendor1@users.noreply.github.com> | 2026-07-10 20:27:10 -0400 |
|---|---|---|
| committer | WizardOfYendor1 <WizardOfYendor1@users.noreply.github.com> | 2026-07-10 20:27:10 -0400 |
| commit | a3ec1a3712f162c3704913b64026585316fbdd31 (patch) | |
| tree | a15ac7dd6f0fb288ceb821bb203239a2b597d723 | |
| parent | 6bbd6dcd447223b0b12f37bd7e40a306ffcb0947 (diff) | |
Add Live TV published URL regression coverage
| -rw-r--r-- | tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs | 131 | ||||
| -rw-r--r-- | tests/Jellyfin.Networking.Tests/NetworkParseTests.cs | 92 |
2 files changed, 223 insertions, 0 deletions
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs index 6dd2f27af7..d93935a9d0 100644 --- a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs +++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs @@ -230,6 +230,7 @@ namespace Jellyfin.Api.Tests.Helpers [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 @@ -283,6 +284,136 @@ namespace Jellyfin.Api.Tests.Helpers 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", diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 3a5b874682..5f7a0efe8a 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; @@ -507,6 +508,10 @@ namespace Jellyfin.Networking.Tests [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 @@ -527,5 +532,92 @@ namespace Jellyfin.Networking.Tests 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); + } + + /// <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); + } } } |
