aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorWizardOfYendor1 <WizardOfYendor1@users.noreply.github.com>2026-07-10 20:26:29 -0400
committerWizardOfYendor1 <WizardOfYendor1@users.noreply.github.com>2026-07-10 20:26:29 -0400
commit6bbd6dcd447223b0b12f37bd7e40a306ffcb0947 (patch)
tree62222f2148d2ec2f04191e922b87aa72846217a9
parentce43df6f43b851e7b09dd6b91ed52a3335feb7a2 (diff)
Resolve Live TV client stream URLs per request
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs3
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs1
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs93
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs252
4 files changed, 343 insertions, 6 deletions
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index ac7c091f85..aa942e7642 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController
return NotFound();
}
- return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false);
+ return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false);
}
/// <summary>
@@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController
var info = await _mediaInfoHelper.GetPlaybackInfo(
item,
user,
+ Request,
mediaSourceId,
liveStreamId)
.ConfigureAwait(false);
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index e53d15acfd..cdbd1ee7aa 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController
var info = await _mediaInfoHelper.GetPlaybackInfo(
item,
user,
+ Request,
mediaSourceId)
.ConfigureAwait(false);
diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
index ef81235808..f178a79999 100644
--- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
+using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Entities;
@@ -44,6 +45,7 @@ public class MediaInfoHelper
private readonly ILogger<MediaInfoHelper> _logger;
private readonly INetworkManager _networkManager;
private readonly IDeviceManager _deviceManager;
+ private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
@@ -56,6 +58,7 @@ public class MediaInfoHelper
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
+ /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
public MediaInfoHelper(
IUserManager userManager,
ILibraryManager libraryManager,
@@ -64,7 +67,8 @@ public class MediaInfoHelper
IServerConfigurationManager serverConfigurationManager,
ILogger<MediaInfoHelper> logger,
INetworkManager networkManager,
- IDeviceManager deviceManager)
+ IDeviceManager deviceManager,
+ IServerApplicationHost appHost)
{
_userManager = userManager;
_libraryManager = libraryManager;
@@ -74,6 +78,7 @@ public class MediaInfoHelper
_logger = logger;
_networkManager = networkManager;
_deviceManager = deviceManager;
+ _appHost = appHost;
}
/// <summary>
@@ -81,12 +86,14 @@ public class MediaInfoHelper
/// </summary>
/// <param name="item">The item.</param>
/// <param name="user">The user.</param>
+ /// <param name="request">The current <see cref="HttpRequest"/>.</param>
/// <param name="mediaSourceId">Media source id.</param>
/// <param name="liveStreamId">Live stream id.</param>
/// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
public async Task<PlaybackInfoResponse> GetPlaybackInfo(
BaseItem item,
User? user,
+ HttpRequest request,
string? mediaSourceId = null,
string? liveStreamId = null)
{
@@ -136,6 +143,11 @@ public class MediaInfoHelper
mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource;
}
+ foreach (var mediaSource in mediaSourcesClone)
+ {
+ RewritePublishedLiveStreamPath(mediaSource, request);
+ }
+
result.MediaSources = mediaSourcesClone;
}
@@ -415,6 +427,8 @@ public class MediaInfoHelper
{
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
+ RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request);
+
var profile = request.DeviceProfile;
if (profile is null)
{
@@ -524,4 +538,81 @@ public class MediaInfoHelper
return maxBitrate;
}
+
+ /// <summary>
+ /// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published
+ /// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local
+ /// addresses. Only opened live streams are eligible. The shared instance held by
+ /// <see cref="IMediaSourceManager"/> is never touched by this method.
+ /// </summary>
+ /// <param name="mediaSource">The media source clone to rewrite in place.</param>
+ /// <param name="request">The current <see cref="HttpRequest"/>.</param>
+ private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request)
+ {
+ // Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources.
+ if (string.IsNullOrEmpty(mediaSource.LiveStreamId))
+ {
+ return;
+ }
+
+ if (mediaSource.Protocol != MediaProtocol.Http)
+ {
+ return;
+ }
+
+ var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
+ var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl);
+
+ if (publishedPath is not null)
+ {
+ mediaSource.Path = publishedPath;
+ return;
+ }
+
+ if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
+ {
+ _logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id);
+ }
+ }
+
+ /// <summary>
+ /// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent.
+ /// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL.
+ /// </summary>
+ /// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param>
+ /// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param>
+ /// <param name="protocol">The media source's protocol.</param>
+ /// <param name="baseUrl">The server's configured BaseUrl, if any.</param>
+ /// <returns>The published path, or null if the local path should be left unchanged.</returns>
+ internal static string? GetPublishedLiveStreamPath(
+ string smartApiUrl,
+ string? localPath,
+ MediaProtocol protocol,
+ string baseUrl)
+ {
+ if (protocol != MediaProtocol.Http
+ || !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri))
+ {
+ return null;
+ }
+
+ var relativePath = localUri.PathAndQuery;
+ if (!string.IsNullOrEmpty(baseUrl))
+ {
+ var basePrefix = baseUrl + "/";
+ if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ relativePath = relativePath[baseUrl.Length..];
+ }
+
+ if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ return smartApiUrl.TrimEnd('/') + relativePath;
+ }
}
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
index a003be4d96..6dd2f27af7 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,230 @@ 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")]
+ 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);
+ }
+
+ [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",
+ "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);
+ }
}
}