aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCody Robibero <cody@robibe.ro>2026-08-02 14:23:49 -0400
committerGitHub <noreply@github.com>2026-08-02 14:23:49 -0400
commitf865910a908ba36c5adfd7d6b394ec239077c31e (patch)
tree0ea2102dc3764b92e75525634e3e954298a0ca8f /tests
parentf2360c07a37b29b6fbfd53cda019c8c3235adfc0 (diff)
parent0c7428f13675d1b63234cdc3ef5c748eb998e8e9 (diff)
Merge pull request #17298 from WizardOfYendor1/fix/livetv-published-stream-urls
Fix Live TV returning unreachable "server-local" streaming URLs to clients.
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs425
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs215
2 files changed, 636 insertions, 4 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.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);
+ }
}
}