Add Live TV published URL regression coverage

This commit is contained in:
WizardOfYendor1
2026-07-10 20:27:10 -04:00
parent 6bbd6dcd44
commit a3ec1a3712
2 changed files with 223 additions and 0 deletions
@@ -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",
@@ -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);
}
}
}