diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs index ed083136e..bbebfd43d 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelGuideHandler.cs @@ -1,9 +1,12 @@ using System.Collections.Immutable; using System.IO.Abstractions; +using System.Security; using System.Text; using System.Text.RegularExpressions; using ErsatzTV.Core; +using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Iptv; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; @@ -15,7 +18,8 @@ public partial class GetChannelGuideHandler( IDbContextFactory dbContextFactory, RecyclableMemoryStreamManager recyclableMemoryStreamManager, IFileSystem fileSystem, - ILocalFileSystem localFileSystem) + ILocalFileSystem localFileSystem, + IConfigElementRepository configElementRepository) : IRequestHandler> { public async Task> Handle( @@ -23,6 +27,21 @@ public partial class GetChannelGuideHandler( CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + Option maybeBaseUrl = + await configElementRepository.GetValue(ConfigElementKey.IptvBaseUrl, cancellationToken); + + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( + maybeBaseUrl.IfNone(string.Empty), + request.Scheme, + request.Host, + request.BaseUrl); + + // The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already + // emitted as &), so the substituted base must be XML-escaped. A path prefix can legally + // contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and + // malform the whole guide. Normal URLs have no special chars, so this is a no-op for them. + string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}"); var hiddenChannelNumbers = dbContext.Channels .Where(c => c.ShowInEpg == false) .Select(c => c.Number) @@ -48,7 +67,7 @@ public partial class GetChannelGuideHandler( // TODO: is regex faster? channelsFragment = channelsFragment - .Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") + .Replace("{RequestBase}", requestBase) .Replace("{AccessTokenUri}", accessTokenUri); var channelDataFragments = new Dictionary(); @@ -70,7 +89,7 @@ public partial class GetChannelGuideHandler( string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken); channelDataFragment = channelDataFragment - .Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}") + .Replace("{RequestBase}", requestBase) .Replace("{AccessTokenUri}", accessTokenUri); channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty); diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs index 25bfb1b01..fccb93249 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelPlaylistHandler.cs @@ -4,19 +4,31 @@ using ErsatzTV.Core.Iptv; namespace ErsatzTV.Application.Channels; -public class GetChannelPlaylistHandler(IChannelRepository channelRepository) +public class GetChannelPlaylistHandler( + IChannelRepository channelRepository, + IConfigElementRepository configElementRepository) : IRequestHandler { - public Task Handle(GetChannelPlaylist request, CancellationToken cancellationToken) => - channelRepository.GetAll(cancellationToken) - .Map(channels => EnsureMode(channels, request.Mode)) - .Map(channels => new ChannelPlaylist( - request.Scheme, - request.Host, - request.BaseUrl, - channels, - request.UserAgent, - request.AccessToken)); + public async Task Handle(GetChannelPlaylist request, CancellationToken cancellationToken) + { + Option maybeBaseUrl = + await configElementRepository.GetValue(ConfigElementKey.IptvBaseUrl, cancellationToken); + + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( + maybeBaseUrl.IfNone(string.Empty), + request.Scheme, + request.Host, + request.BaseUrl); + + List channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode); + return new ChannelPlaylist( + scheme, + host, + baseUrl, + channels, + request.UserAgent, + request.AccessToken); + } private static List EnsureMode(IEnumerable channels, string mode) { diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettings.cs b/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettings.cs new file mode 100644 index 000000000..c87ffd865 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettings.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Configuration; + +public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest>; diff --git a/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettingsHandler.cs b/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettingsHandler.cs new file mode 100644 index 000000000..f6b07e766 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Commands/UpdateIptvSettingsHandler.cs @@ -0,0 +1,51 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Iptv; + +namespace ErsatzTV.Application.Configuration; + +public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler> +{ + public async Task> Handle( + UpdateIptvSettings request, + CancellationToken cancellationToken) + { + Validation validation = Validate(request); + return await validation.Apply(_ => ApplyUpdate(request.IptvSettings, cancellationToken)); + } + + private async Task ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken) + { + string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim(); + + // A blank value clears the setting so the request-derived behavior is restored. + if (string.IsNullOrWhiteSpace(baseUrl)) + { + await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken); + } + else + { + await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken); + } + + return Unit.Default; + } + + private static Validation Validate(UpdateIptvSettings request) + { + string baseUrl = request.IptvSettings.BaseUrl; + + // Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL. + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return Unit.Default; + } + + return AdvertisedBaseUrl.TryParse(baseUrl) + .Map(_ => Unit.Default) + .ToValidation( + "Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment"); + } +} diff --git a/ErsatzTV.Application/Configuration/IptvSettingsViewModel.cs b/ErsatzTV.Application/Configuration/IptvSettingsViewModel.cs new file mode 100644 index 000000000..22692835b --- /dev/null +++ b/ErsatzTV.Application/Configuration/IptvSettingsViewModel.cs @@ -0,0 +1,6 @@ +namespace ErsatzTV.Application.Configuration; + +public class IptvSettingsViewModel +{ + public string BaseUrl { get; set; } +} diff --git a/ErsatzTV.Application/Configuration/Queries/GetIptvSettings.cs b/ErsatzTV.Application/Configuration/Queries/GetIptvSettings.cs new file mode 100644 index 000000000..e8ca14b8a --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetIptvSettings.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Configuration; + +public record GetIptvSettings : IRequest; diff --git a/ErsatzTV.Application/Configuration/Queries/GetIptvSettingsHandler.cs b/ErsatzTV.Application/Configuration/Queries/GetIptvSettingsHandler.cs new file mode 100644 index 000000000..02bc97643 --- /dev/null +++ b/ErsatzTV.Application/Configuration/Queries/GetIptvSettingsHandler.cs @@ -0,0 +1,19 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; + +namespace ErsatzTV.Application.Configuration; + +public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository) + : IRequestHandler +{ + public async Task Handle(GetIptvSettings request, CancellationToken cancellationToken) + { + Option maybeBaseUrl = + await configElementRepository.GetValue(ConfigElementKey.IptvBaseUrl, cancellationToken); + + return new IptvSettingsViewModel + { + BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty) + }; + } +} diff --git a/ErsatzTV.Core.Tests/Iptv/AdvertisedBaseUrlTests.cs b/ErsatzTV.Core.Tests/Iptv/AdvertisedBaseUrlTests.cs new file mode 100644 index 000000000..63e56de77 --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/AdvertisedBaseUrlTests.cs @@ -0,0 +1,127 @@ +using ErsatzTV.Core.Iptv; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Iptv; + +[TestFixture] +public class AdvertisedBaseUrlTests +{ + private const string RequestScheme = "http"; + private const string RequestHost = "ersatztv:8409"; + private const string RequestBaseUrl = ""; + + [Test] + public void Resolve_Uses_Configured_Value_When_Set() + { + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( + "https://tv.example.com", + RequestScheme, + RequestHost, + RequestBaseUrl); + + scheme.ShouldBe("https"); + host.ShouldBe("tv.example.com"); + baseUrl.ShouldBe(""); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Resolve_Falls_Back_To_Request_When_Blank(string configured) + { + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( + configured, + RequestScheme, + RequestHost, + RequestBaseUrl); + + scheme.ShouldBe(RequestScheme); + host.ShouldBe(RequestHost); + baseUrl.ShouldBe(RequestBaseUrl); + } + + [Test] + public void Resolve_Falls_Back_To_Request_When_Invalid() + { + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve( + "not a url", + RequestScheme, + RequestHost, + RequestBaseUrl); + + scheme.ShouldBe(RequestScheme); + host.ShouldBe(RequestHost); + baseUrl.ShouldBe(RequestBaseUrl); + } + + [Test] + public void TryParse_Preserves_Non_Default_Port() + { + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse("http://192.168.1.99:8409").IfNone(("", "", "")); + + scheme.ShouldBe("http"); + host.ShouldBe("192.168.1.99:8409"); + baseUrl.ShouldBe(""); + } + + [TestCase("http://tv.example.com:80", "http", "tv.example.com")] + [TestCase("https://tv.example.com:443", "https", "tv.example.com")] + public void TryParse_Drops_Redundant_Default_Port(string configured, string expectedScheme, string expectedHost) + { + (string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", "")); + + scheme.ShouldBe(expectedScheme); + host.ShouldBe(expectedHost); + baseUrl.ShouldBe(""); + } + + [Test] + public void TryParse_Preserves_Path_Prefix() + { + (string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse("https://tv.example.com/etv").IfNone(("", "", "")); + baseUrl.ShouldBe("/etv"); + } + + [TestCase("https://tv.example.com/", "")] + [TestCase("https://tv.example.com/etv/", "/etv")] + [TestCase("https://tv.example.com/etv//", "/etv")] + public void TryParse_Normalizes_Trailing_Slash(string configured, string expectedBaseUrl) + { + (string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", "")); + baseUrl.ShouldBe(expectedBaseUrl); + } + + [Test] + public void TryParse_Trims_Whitespace() + { + Option<(string Scheme, string Host, string BaseUrl)> result = + AdvertisedBaseUrl.TryParse(" https://tv.example.com/etv "); + + result.IsSome.ShouldBeTrue(); + result.IfNone(("", "", "")).BaseUrl.ShouldBe("/etv"); + } + + [Test] + public void TryParse_Handles_IPv6_Host() + { + (string _, string host, string _) = AdvertisedBaseUrl.TryParse("http://[2001:db8::1]:8409").IfNone(("", "", "")); + host.ShouldBe("[2001:db8::1]:8409"); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + [TestCase("not a url")] + [TestCase("tv.example.com")] // no scheme (relative) + [TestCase("//tv.example.com")] // protocol-relative (not absolute) + [TestCase("ftp://tv.example.com")] // non-http(s) scheme + [TestCase("ws://tv.example.com")] // non-http(s) scheme + [TestCase("http://user:pass@tv.example.com")] // credentials + [TestCase("http://tv.example.com?foo=bar")] // query + [TestCase("http://tv.example.com/etv?foo=bar")] // query on a path + [TestCase("http://tv.example.com#frag")] // fragment + [TestCase("http://")] // no host + public void TryParse_Rejects_Invalid_Input(string configured) => + AdvertisedBaseUrl.TryParse(configured).IsNone.ShouldBeTrue(); +} diff --git a/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs b/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs index b7502342a..a78591793 100644 --- a/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs +++ b/ErsatzTV.Core.Tests/Iptv/ChannelGuideGoldenTests.cs @@ -4,6 +4,7 @@ using ErsatzTV.Application.Channels; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Metadata; +using ErsatzTV.Core.Interfaces.Repositories; using ErsatzTV.Core.Iptv; using ErsatzTV.Infrastructure; using ErsatzTV.Infrastructure.Data; @@ -104,6 +105,88 @@ public class ChannelGuideGoldenTests public Task Guide_with_base_url() => Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null)); + // When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the + // request-derived scheme/host — proving the override reaches both fragment substitution sites. + [Test] + public async Task Guide_uses_advertised_base_url_when_configured() + { + MockFileSystem fileSystem = BuildCacheFileSystem(); + var localFileSystem = Substitute.For(); + localFileSystem + .ListFiles(FileSystemLayout.ChannelGuideCacheFolder) + .Returns(new[] + { + FragmentPath(fileSystem, "channels.xml"), + FragmentPath(fileSystem, "2.xml") + }); + + var configElementRepository = Substitute.For(); + configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some("https://public.example.com/etv")); + + var handler = new GetChannelGuideHandler( + _dbContextFactory, + new RecyclableMemoryStreamManager(), + fileSystem, + localFileSystem, + configElementRepository); + + Either result = await handler.Handle( + new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null), + CancellationToken.None); + + string xml = result.Match( + Right: guide => guide.ToXml(), + Left: error => throw new AssertionException($"Handler returned error: {error.Value}")); + + // The advertised origin replaces {RequestBase} on both the channel and the programme . + xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg"); + xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg"); + xml.ShouldNotContain(Host); + } + + // A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL + // path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into + // the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.) + [Test] + public async Task Guide_xml_escapes_advertised_base_url() + { + MockFileSystem fileSystem = BuildCacheFileSystem(); + var localFileSystem = Substitute.For(); + localFileSystem + .ListFiles(FileSystemLayout.ChannelGuideCacheFolder) + .Returns(new[] + { + FragmentPath(fileSystem, "channels.xml"), + FragmentPath(fileSystem, "2.xml") + }); + + var configElementRepository = Substitute.For(); + configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some("https://tv.example.com/a&b")); + + var handler = new GetChannelGuideHandler( + _dbContextFactory, + new RecyclableMemoryStreamManager(), + fileSystem, + localFileSystem, + configElementRepository); + + Either result = await handler.Handle( + new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null), + CancellationToken.None); + + string xml = result.Match( + Right: guide => guide.ToXml(), + Left: error => throw new AssertionException($"Handler returned error: {error.Value}")); + + // The '&' from the base URL must be emitted as '&', never a bare '&'. + xml.ShouldContain("https://tv.example.com/a&b/iptv/logos/news.jpg"); + xml.ShouldNotContain("a&b"); + } + // --- harness --- private async Task Verify(string goldenName, GetChannelGuide request) @@ -123,11 +206,19 @@ public class ChannelGuideGoldenTests FragmentPath(fileSystem, "2.xml") }); + // No advertised base URL configured — the {RequestBase} substitution must use the request-derived + // scheme/host/base, keeping today's output byte-for-byte identical (issue #340). + var configElementRepository = Substitute.For(); + configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.None); + var handler = new GetChannelGuideHandler( _dbContextFactory, new RecyclableMemoryStreamManager(), fileSystem, - localFileSystem); + localFileSystem, + configElementRepository); Either result = await handler.Handle(request, CancellationToken.None); diff --git a/ErsatzTV.Core.Tests/Iptv/GetChannelPlaylistHandlerTests.cs b/ErsatzTV.Core.Tests/Iptv/GetChannelPlaylistHandlerTests.cs new file mode 100644 index 000000000..e26398daf --- /dev/null +++ b/ErsatzTV.Core.Tests/Iptv/GetChannelPlaylistHandlerTests.cs @@ -0,0 +1,71 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Iptv; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Core.Tests.Iptv; + +[TestFixture] +public class GetChannelPlaylistHandlerTests +{ + private const string Scheme = "https"; + private const string Host = "tv.example.com"; + + [Test] + public async Task Uses_Advertised_Base_Url_When_Configured() + { + string m3u = await BuildM3U(configuredBaseUrl: "https://public.example.com/etv"); + + m3u.ShouldContain("https://public.example.com/etv/iptv/channel/1."); + m3u.ShouldContain("https://public.example.com/etv/iptv/xmltv.xml"); + m3u.ShouldNotContain(Host); + } + + [Test] + public async Task Falls_Back_To_Request_When_Unset() + { + string m3u = await BuildM3U(configuredBaseUrl: null); + + m3u.ShouldContain("https://tv.example.com/iptv/channel/1."); + m3u.ShouldContain("https://tv.example.com/iptv/xmltv.xml"); + m3u.ShouldNotContain("public.example.com"); + } + + private static async Task BuildM3U(string configuredBaseUrl) + { + var channelRepository = Substitute.For(); + channelRepository.GetAll(Arg.Any()).Returns([BuildChannel()]); + + var configElementRepository = Substitute.For(); + configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Optional(configuredBaseUrl)); + + var handler = new GetChannelPlaylistHandler(channelRepository, configElementRepository); + + ChannelPlaylist playlist = await handler.Handle( + new GetChannelPlaylist(Scheme, Host, BaseUrl: "", Mode: "mixed", UserAgent: "VLC/3.0", AccessToken: null), + CancellationToken.None); + + return playlist.ToM3U(); + } + + private static Channel BuildChannel() => + new(new Guid("00000000-0000-0000-0000-000000000001")) + { + Number = "1", + Name = "News", + Group = "ErsatzTV", + IsEnabled = true, + StreamingMode = StreamingMode.HttpLiveStreamingDirect, + Artwork = [], + FFmpegProfile = new FFmpegProfile + { + VideoFormat = FFmpegProfileVideoFormat.H264, + AudioFormat = FFmpegProfileAudioFormat.Aac + } + }; +} diff --git a/ErsatzTV.Core/Api/Settings/IptvSettingsResponseModel.cs b/ErsatzTV.Core/Api/Settings/IptvSettingsResponseModel.cs new file mode 100644 index 000000000..e75e6048a --- /dev/null +++ b/ErsatzTV.Core/Api/Settings/IptvSettingsResponseModel.cs @@ -0,0 +1,9 @@ +namespace ErsatzTV.Core.Api.Settings; + +#nullable enable + +/// +/// IPTV output settings. is the optional advertised base URL applied to +/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path". +/// +public record IptvSettingsResponseModel(string BaseUrl); diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 42de2398e..36f5fe611 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -62,6 +62,11 @@ public class ConfigElementKey public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build"); public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior"); + // Optional advertised IPTV base URL (issue #340). When set, overrides the request-derived + // scheme/host/PathBase used to build absolute M3U + XMLTV URLs; when blank/unset, request-derived + // values are used (today's behavior). Distinct from ETV_BASE_URL, which only sets ASP.NET PathBase. + public static ConfigElementKey IptvBaseUrl => new("iptv.base_url"); + // Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement // rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on // every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions. diff --git a/ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs b/ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs new file mode 100644 index 000000000..64e091d06 --- /dev/null +++ b/ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs @@ -0,0 +1,67 @@ +namespace ErsatzTV.Core.Iptv; + +// Central resolution/validation for the optional advertised IPTV base URL (issue #340). +// +// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase. +// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g. +// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted +// URLs break. An operator can configure an advertised base URL to override those request-derived +// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces. +// +// When the configured value is blank or invalid, resolution falls back to the request-derived values +// so today's behavior is preserved byte-for-byte. +public static class AdvertisedBaseUrl +{ + // Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured + // value is blank or invalid, returns the request-derived values unchanged. + public static (string Scheme, string Host, string BaseUrl) Resolve( + string configured, + string requestScheme, + string requestHost, + string requestBaseUrl) => + TryParse(configured).Match( + Some: parsed => parsed, + None: () => (requestScheme, requestHost, requestBaseUrl)); + + // Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an + // absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix + // are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and + // "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate). + public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured) + { + if (string.IsNullOrWhiteSpace(configured)) + { + return None; + } + + if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri)) + { + return None; + } + + if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + { + return None; + } + + if (!string.IsNullOrEmpty(uri.UserInfo)) + { + return None; + } + + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + { + return None; + } + + if (string.IsNullOrEmpty(uri.Host)) + { + return None; + } + + // Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets) + // and excludes any userinfo — exactly the "{host}" the URL builders expect. + string baseUrl = uri.AbsolutePath.TrimEnd('/'); + return (uri.Scheme, uri.Authority, baseUrl); + } +} diff --git a/ErsatzTV.Tests/Application/Configuration/IptvSettingsHandlerTests.cs b/ErsatzTV.Tests/Application/Configuration/IptvSettingsHandlerTests.cs new file mode 100644 index 000000000..916bdcc09 --- /dev/null +++ b/ErsatzTV.Tests/Application/Configuration/IptvSettingsHandlerTests.cs @@ -0,0 +1,108 @@ +using ErsatzTV.Application.Configuration; +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using LanguageExt; +using NSubstitute; +using NUnit.Framework; +using Shouldly; +using Unit = LanguageExt.Unit; + +namespace ErsatzTV.Tests.Application.Configuration; + +[TestFixture] +public class IptvSettingsHandlerTests +{ + private const string Key = "iptv.base_url"; + + private IConfigElementRepository _configElementRepository = null!; + + [SetUp] + public void SetUp() => _configElementRepository = Substitute.For(); + + [Test] + public async Task Get_Returns_Empty_When_Unset() + { + _configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.None); + + var handler = new GetIptvSettingsHandler(_configElementRepository); + + IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None); + + result.BaseUrl.ShouldBe(string.Empty); + } + + [Test] + public async Task Get_Returns_Stored_Value() + { + _configElementRepository + .GetValue(Arg.Any(), Arg.Any()) + .Returns(Option.Some("https://tv.example.com/etv")); + + var handler = new GetIptvSettingsHandler(_configElementRepository); + + IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None); + + result.BaseUrl.ShouldBe("https://tv.example.com/etv"); + } + + [Test] + public async Task Update_Upserts_Trimmed_Value_When_Valid() + { + var handler = new UpdateIptvSettingsHandler(_configElementRepository); + + Either result = await handler.Handle( + new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _configElementRepository.Received(1).Upsert( + Arg.Is(k => k.Key == Key), + "https://public.example.com/etv", + Arg.Any()); + await _configElementRepository.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public async Task Update_Clears_Setting_When_Blank(string blank) + { + var handler = new UpdateIptvSettingsHandler(_configElementRepository); + + Either result = await handler.Handle( + new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }), + CancellationToken.None); + + result.IsRight.ShouldBeTrue(); + await _configElementRepository.Received(1).Delete( + Arg.Is(k => k.Key == Key), + Arg.Any()); + await _configElementRepository.DidNotReceive().Upsert( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [TestCase("not a url")] + [TestCase("ftp://tv.example.com")] + [TestCase("http://user:pass@tv.example.com")] + [TestCase("http://tv.example.com?foo=bar")] + public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid) + { + var handler = new UpdateIptvSettingsHandler(_configElementRepository); + + Either result = await handler.Handle( + new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }), + CancellationToken.None); + + result.IsLeft.ShouldBeTrue(); + await _configElementRepository.DidNotReceive().Upsert( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _configElementRepository.DidNotReceive().Delete(Arg.Any(), Arg.Any()); + } +} diff --git a/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs b/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs index c04bf335e..1b102266b 100644 --- a/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/SettingsControllerTests.cs @@ -242,6 +242,48 @@ public class SettingsControllerTests result.ShouldBeOfType(); } + [Test] + public async Task GetIptv_Should_Map_Vm_To_Response_Model() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" }); + + IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None); + + result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv")); + } + + [Test] + public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Right(Unit.Default)); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" }); + + IActionResult result = await _controller.UpdateIptv( + new UpdateIptvSettingsRequest("https://public.example.com"), + CancellationToken.None); + + result.ShouldBeOfType().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com")); + await _mediator.Received(1).Send( + Arg.Is(c => c.IptvSettings.BaseUrl == "https://public.example.com"), + Arg.Any()); + } + + [Test] + public async Task UpdateIptv_Should_Return_422_On_Validation_Error() + { + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(Left(BaseError.New("bad"))); + + IActionResult result = await _controller.UpdateIptv( + new UpdateIptvSettingsRequest("not a url"), + CancellationToken.None); + + result.ShouldBeOfType(); + } + [Test] public async Task GetScanner_Should_Return_Library_Refresh_Interval() { diff --git a/ErsatzTV/Controllers/Api/Requests/UpdateIptvSettingsRequest.cs b/ErsatzTV/Controllers/Api/Requests/UpdateIptvSettingsRequest.cs new file mode 100644 index 000000000..fdbcd7dea --- /dev/null +++ b/ErsatzTV/Controllers/Api/Requests/UpdateIptvSettingsRequest.cs @@ -0,0 +1,13 @@ +using ErsatzTV.Application.Configuration; + +namespace ErsatzTV.Controllers.Api.Requests; + +/// +/// IPTV output settings. is the optional advertised base URL applied to +/// absolute M3U/XMLTV URLs; send an empty string to clear it and use the incoming request's origin. +/// +public record UpdateIptvSettingsRequest(string BaseUrl) +{ + public UpdateIptvSettings ToCommand() => + new(new IptvSettingsViewModel { BaseUrl = BaseUrl }); +} diff --git a/ErsatzTV/Controllers/Api/SettingsController.cs b/ErsatzTV/Controllers/Api/SettingsController.cs index cf3f6af66..55060c51c 100644 --- a/ErsatzTV/Controllers/Api/SettingsController.cs +++ b/ErsatzTV/Controllers/Api/SettingsController.cs @@ -121,6 +121,39 @@ public class SettingsController(IMediator mediator) : ControllerBase }); } + // IPTV settings + + [HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")] + [Tags("Settings")] + [EndpointSummary("Get IPTV output settings")] + [ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)] + public async Task GetIptv(CancellationToken cancellationToken) + { + IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken); + return ProjectToResponseModel(settings); + } + + [HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")] + [Tags("Settings")] + [EndpointSummary("Update IPTV output settings")] + [ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)] + public async Task UpdateIptv( + [Required] [FromBody] + UpdateIptvSettingsRequest request, + CancellationToken cancellationToken) + { + Either result = await mediator.Send(request.ToCommand(), cancellationToken); + return await result.Match( + Left: error => Task.FromResult(error.ToErrorResult()), + Right: async _ => + { + IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken); + return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings)); + }); + } + // Scanner settings [HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")] @@ -274,6 +307,9 @@ public class SettingsController(IMediator mediator) : ControllerBase private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) => new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds); + private static IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) => + new(vm.BaseUrl); + private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) => new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior)); diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index 77ed5c7e7..c51fb78de 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -18196,6 +18196,157 @@ ] } }, + "/api/v1/settings/iptv": { + "get": { + "tags": [ + "Settings" + ], + "summary": "Get IPTV output settings", + "operationId": "GetIptvSettings", + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + } + } + }, + "401": { + "description": "API key missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Settings" + ], + "summary": "Update IPTV output settings", + "operationId": "UpdateIptvSettings", + "requestBody": { + "content": { + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/UpdateIptvSettingsRequest" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateIptvSettingsRequest" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/UpdateIptvSettingsRequest" + } + }, + "application/*+json": { + "schema": { + "$ref": "#/components/schemas/UpdateIptvSettingsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/IptvSettingsResponseModel" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "422": { + "description": "Unprocessable Entity", + "content": { + "text/plain": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + }, + "text/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Request validation failed (model binding or FluentValidation).", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidationProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, "/api/v1/settings/scanner": { "get": { "tags": [ @@ -25948,6 +26099,17 @@ } } }, + "IptvSettingsResponseModel": { + "required": [ + "baseUrl" + ], + "type": "object", + "properties": { + "baseUrl": { + "type": "string" + } + } + }, "LanguageCodeResponseModel": { "required": [ "code", @@ -30988,6 +31150,20 @@ } } }, + "UpdateIptvSettingsRequest": { + "required": [ + "baseUrl" + ], + "type": "object", + "properties": { + "baseUrl": { + "type": [ + "null", + "string" + ] + } + } + }, "UpdateLocalLibraryPathRequest": { "required": [ "id", diff --git a/docs/decisions.md b/docs/decisions.md index 65c14e086..a25ce3fed 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -90,6 +90,7 @@ keep append-only from accreting stale, contradictory, or unreadably-large histor - [2026-07-13 — Scheduling API hardening: null-name 500s, duplicate template items, unreachable 404 (#172)](#2026-07-13--scheduling-api-hardening-null-name-500s-duplicate-template-items-unreachable-404-172) - [2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)](#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335) - [2026-07-16 — Functional-E2E CI harness: advisory curl-contract job over an app booted from source (#299)](#2026-07-16--functional-e2e-ci-harness-advisory-curl-contract-job-over-an-app-booted-from-source-299) +- [2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340)](#2026-07-16--optional-advertised-iptv-base-url-iptvbase_url-resolved-centrally-in-the-two-generators-340) --- @@ -1874,3 +1875,49 @@ empty local library still enqueues `202`; an empty collection drives the concurr and the genuinely UI-interactive Playwright flows are deferred as #299 follow-ups rather than shipped flaky. Assertions were written against a real running instance, not the source — which caught that `/artwork/*` returns `400` (not the `404` a static read suggested); extend the harness the same way. + +## 2026-07-16 — Optional advertised IPTV base URL (`iptv.base_url`) resolved centrally in the two generators (#340) + +ErsatzTV's absolute IPTV URLs (M3U stream/logo/guide URLs, XMLTV ``/artwork) were always +derived from the incoming request's `Scheme`/`Host`/`PathBase`, so any client that fetched with a +host downstream consumers can't resolve (the historical Gitea #1 `localhost:8409` symptom) baked +that host into the output. #340 adds an **optional** advertised base URL to pin those URLs to a +fixed public origin. Several deliberate decisions shaped it: + +**(a) Resolve the override centrally in the two generation handlers, via a pure Core helper — not +in the controller.** A new `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs` exposes `TryParse(string) → +Option<(scheme, host, baseUrl)>` and `Resolve(configured, requestScheme, requestHost, +requestBaseUrl)`. `GetChannelPlaylistHandler` (M3U, via `ChannelPlaylist`) and +`GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites) call `Resolve` and use its +result instead of the raw request values. Keeping the logic in a pure, allocation-free Core helper +(not the thin controller) keeps controllers dumb, makes the parse/validate/resolve rules unit-testable +in isolation, and — because the fallback path returns the exact request-derived values — leaves the +M3U/XMLTV golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) untouched. + +**(b) Validation rules, with blank/invalid → request-derived so "unset" is byte-identical.** +`TryParse` accepts only an **absolute http(s)** URL with **no credentials, query, or fragment**; it +**preserves the port and any path prefix** and **normalizes a trailing slash** off. Anything failing +these rules → `None`, and `Resolve` then falls back to the request-derived scheme/host/base. So an +unset or malformed value produces output byte-for-byte identical to today's request-derived behaviour +— the override is strictly opt-in and can never silently corrupt the default path. + +**(c) A NEW `iptv` settings group, not folded under `xmltv`.** The base URL affects **both** the M3U +playlist and the XMLTV guide, so it does not belong under the existing XMLTV-only settings. `GET`/`PUT +/api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`) with body `{ baseUrl }`, +backed by `GetIptvSettings`/`UpdateIptvSettings` handlers + `IptvSettingsViewModel` / +`IptvSettingsResponseModel` / `UpdateIptvSettingsRequest`, and a new "IPTV" section on the SPA Settings +screen. Follows the existing settings GET/PUT pattern — no new API convention. + +**(d) Blank clears the key; non-blank malformed → 422.** A blank/whitespace `baseUrl` on PUT +**deletes** the `ConfigElement` (reverting to request-derived); a non-blank value that fails +`AdvertisedBaseUrl.TryParse` is rejected with **422** rather than being silently stored and ignored at +generation time — the error surfaces at the point of configuration. + +**(e) Scoped to M3U + XMLTV, deliberately NOT HDHomeRun.** #340 covers only the M3U playlist and XMLTV +guide generators. The HDHomeRun lineup URLs still echo the request host; extending the override there +was explicitly out of scope. + +**(f) Distinct from `ETV_BASE_URL`.** The `ETV_BASE_URL` environment variable only sets the ASP.NET +Core `PathBase` (request routing/prefix); it does not advertise a scheme+host. `iptv.base_url` is the +separate, DB-stored (`ConfigElementKey.IptvBaseUrl`, **no EF migration**) advertised origin for IPTV +output. See `docs/m3u-xmltv.md` → "IPTV base URL (#340)". diff --git a/docs/domain-model.md b/docs/domain-model.md index 1b59b83b0..cfa546762 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -71,6 +71,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe | **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` | | **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` | | **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — | +| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) | | **Aggregate `Version`** | Optimistic-concurrency token (issue #253): a plain `int Version` on the 9 replace-all roots — `ProgramSchedule`, `Block`, `Template`, `DecoTemplate`, `Playlist`, `Collection`, `Playout`, `MultiCollection`, `RerunCollection` — implementing `IVersionedAggregate`, EF-mapped `.IsConcurrencyToken()`. Surfaced as a strong ETag on the aggregate's GET and checked against `If-Match` on the PUT (mismatch → 412). See `api-conventions.md` §7a. | `IVersionedAggregate` | (not user-edited) | ## Where things are edited (SPA routes) @@ -102,7 +103,8 @@ These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages o `docs/blazor-route-parity.md` Section 2 for the full per-route mapping. System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv — -all mapped 1:1 from legacy `/settings/*` Blazor routes), `/app/logs`, `/app/troubleshooting` (+ +all mapped 1:1 from legacy `/settings/*` Blazor routes — plus a new **IPTV** section, #340, that +edits the advertised IPTV base URL via `GET`/`PUT /api/v1/settings/iptv`), `/app/logs`, `/app/troubleshooting` (+ `/app/troubleshooting/blocks` block-playout history, `/app/troubleshooting/yaml` sequential-schedule validator; playback troubleshooting still gated on API #145), `/app/ffmpeg-profiles`, `/app/watermarks`. diff --git a/docs/endpoint-index.md b/docs/endpoint-index.md index a72acd4c9..a7d7e6242 100644 --- a/docs/endpoint-index.md +++ b/docs/endpoint-index.md @@ -2,7 +2,7 @@ *Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.* -160 endpoints, 242 operations. +161 endpoints, 244 operations. ## Artists @@ -354,6 +354,8 @@ | PUT | `/api/v1/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings | | GET | `/api/v1/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings | | PUT | `/api/v1/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings | +| GET | `/api/v1/settings/iptv` | GetIptvSettings | Get IPTV output settings | +| PUT | `/api/v1/settings/iptv` | UpdateIptvSettings | Update IPTV output settings | | GET | `/api/v1/settings/logging` | GetLoggingSettings | Get per-area minimum log levels | | PUT | `/api/v1/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels | | GET | `/api/v1/settings/playout` | GetPlayoutSettings | Get global playout settings | diff --git a/docs/m3u-xmltv.md b/docs/m3u-xmltv.md index 7870df7ef..e5a8560cb 100644 --- a/docs/m3u-xmltv.md +++ b/docs/m3u-xmltv.md @@ -104,7 +104,7 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope ### Architecture: absolute-URL generation (relevant for future work) -**ErsatzTV has no "advertised/public/base URL" setting.** Every absolute URL it emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`: +**ErsatzTV now has an optional "advertised base URL" setting for IPTV (`iptv.base_url`, #340 — see "IPTV base URL" below).** When it is **unset** (the default), every absolute URL ErsatzTV emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`, exactly as described below; when it is **set**, the two IPTV generators (M3U + XMLTV) use the configured scheme/host/base instead: | Output | Source of host | |--------|----------------| @@ -113,8 +113,8 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope | FFmpeg watermark overlay logo | `ChannelLogoGenerator.GenerateChannelLogoUrl()` — hardcoded `http://localhost:{StreamingPort}` (correct: FFmpeg runs in-container). **Not** part of M3U/XMLTV. | Implications for future work: -- Any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) gets that host echoed back into stream + logo + artwork URLs. This affects the planned REST API (#2) and any reverse-proxy fronting (server-management) too. -- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue` / upsert). Adding a new setting needs **no EF migration**. +- With `iptv.base_url` **unset**, any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) still gets that host echoed back into stream + logo + artwork URLs. Setting `iptv.base_url` pins the M3U + XMLTV URLs to a fixed public origin regardless of the request `Host`, which is the mitigation for a reverse-proxy / split-DNS deployment (server-management). The HDHomeRun lineup URLs are **not** covered by `iptv.base_url` (deliberately out of scope for #340) and still echo the request host. +- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue` / upsert). Adding a new setting needs **no EF migration** — this is exactly how `iptv.base_url` (`ConfigElementKey.IptvBaseUrl`) was added. ### Resolution (2026-06-27) — Gitea #1 closed as config/topology, no code change @@ -138,13 +138,25 @@ Evidence gathered (all read-only): > Note — the forwarded-headers theory (`KnownProxies.Clear()` in `Startup.cs` making `X-Forwarded-*` be *ignored*) is **incorrect**: clearing *both* `KnownProxies` and `KnownNetworks` sets `checkKnownIps = false`, which *trusts all* proxies, not none. And there is no proxy between Jellyfin and ErsatzTV. Red herring. -### Latent fragility (not currently broken) +### Latent fragility (not currently broken) — now configurable via `iptv.base_url` (#340) -The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. ErsatzTV still has no advertised/base-URL setting — every absolute URL is hostage to the request `Host`. If a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns. +The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. By default every absolute URL is still hostage to the request `Host`, so if a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns. -### Optional future hardening (NOT committed) +**The mitigation is now built-in:** set the IPTV base URL (`iptv.base_url`, see below) to a fixed public origin and the M3U + XMLTV URLs stop depending on the request `Host` entirely. It stays **opt-in** — unset behaviour is byte-for-byte the request-derived behaviour described above, so nothing changes for a deployment that doesn't need it. -If that fragility is ever worth removing: add an optional `iptv.base_url` ConfigElement (key/value, **no EF migration**) + a "Base URL" settings field; when set, `GetChannelPlaylistHandler` / `GetChannelGuideHandler` parse it into scheme/host/base instead of the request values; when blank, behaviour is unchanged. Cover with unit tests on `ChannelPlaylist.ToM3U()`. Deliberately **not** built for #1 — the problem no longer manifests, so this stays a documented option rather than committed work. +### IPTV base URL (#340) + +Originally logged here as "optional future hardening (NOT committed)"; **implemented in #340**. An optional advertised base URL for the IPTV surface: + +- **Config**: stored as a single `ConfigElement` under `ConfigElementKey.IptvBaseUrl` (key `iptv.base_url`, key/value, **no EF migration**). Distinct from the `ETV_BASE_URL` environment variable, which only sets the ASP.NET Core `PathBase` (request routing) and does not advertise a host. +- **Helper**: a pure Core helper, `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. + - `TryParse(string)` validates the configured value and returns `Option<(scheme, host, baseUrl)>` — it must be an **absolute http(s)** URL with **no credentials, query, or fragment**; the **port** and any **path prefix** are preserved, and a trailing slash is normalized off. Anything that fails these rules parses to `None`. + - `Resolve(configured, requestScheme, requestHost, requestBaseUrl)` returns the parsed override when the configured value is present and valid, and otherwise **falls back to the request-derived** scheme/host/base. So a blank or malformed value is transparently ignored. +- **Where it's applied**: resolution happens **inside the two generation handlers**, not in the controller — `GetChannelPlaylistHandler` (M3U guide/logo/stream URLs, via `ChannelPlaylist`) and `GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites). Because unset/invalid falls back to the request values, the golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) are unchanged. +- **Scope**: M3U + XMLTV only. Deliberately **NOT** applied to the HDHomeRun lineup URLs (out of scope for #340). +- **API + UI**: managed through a new "IPTV" settings group — `GET`/`PUT /api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`), body `{ baseUrl }`. A **blank** value clears the key; a **non-blank malformed** value → **422**. A new "IPTV" section on the SPA Settings screen edits it. + +Covered by unit tests on `AdvertisedBaseUrl` (parse/validate/resolve) plus the unchanged M3U/XMLTV goldens proving unset = today's output. ### Historical Workaround (pre-Dispatcharr) diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 45635ac8a..811866495 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -766,6 +766,13 @@ function defaultHdhrSettings(overrides: Record = {}): Record = {}): Record { + return { + baseUrl: '', + ...overrides + }; +} + function defaultResolutions(): Array> { return [ { height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }, @@ -952,6 +959,7 @@ function mockDashboardApi({ const settingsResponses: Record = { '/api/v1/settings/ffmpeg': defaultFfmpegSettings(), '/api/v1/settings/hdhr': defaultHdhrSettings(), + '/api/v1/settings/iptv': defaultIptvSettings(), '/api/v1/settings/logging': defaultLoggingSettings(), '/api/v1/settings/playout': defaultPlayoutSettings(), '/api/v1/settings/resolutions': defaultResolutions(), diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index ad9f715d3..88fe97802 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -734,6 +734,9 @@ export interface components { "subfolderCount": number; "imageCount": number; "durationSeconds": null | number; + }; + "IptvSettingsResponseModel": { + "baseUrl": string; }; "LanguageCodeResponseModel": { "code": string; @@ -1657,6 +1660,9 @@ export interface components { }; "UpdateImageFolderDurationResponseModel": { "durationSeconds": null | number; + }; + "UpdateIptvSettingsRequest": { + "baseUrl": null | string; }; "UpdateLocalLibraryPathRequest": { "id": number; diff --git a/web/src/api/settings.test.ts b/web/src/api/settings.test.ts index 44c535cce..79b76be6a 100644 --- a/web/src/api/settings.test.ts +++ b/web/src/api/settings.test.ts @@ -4,6 +4,7 @@ import { deleteResolution, getFfmpegSettings, getHdhrSettings, + getIptvSettings, getLoggingSettings, getPlayoutSettings, getResolutions, @@ -13,6 +14,7 @@ import { messageFromSettingsError, updateFfmpegSettings, updateHdhrSettings, + updateIptvSettings, updateLoggingSettings, updatePlayoutSettings, updateScannerSettings, @@ -145,6 +147,20 @@ describe('settings API module', () => { expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 }); }); + it('fetches and updates IPTV settings', async () => { + const settings = { baseUrl: 'http://192.168.1.99:8409' }; + vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings))); + + await expect(getIptvSettings()).resolves.toMatchObject(settings); + expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/iptv'); + + await updateIptvSettings(settings); + const [path, init] = lastFetchCall(); + expect(path.toString()).toBe('/api/v1/settings/iptv'); + expect(init?.method).toBe('PUT'); + expect(JSON.parse(init?.body as string)).toMatchObject({ baseUrl: 'http://192.168.1.99:8409' }); + }); + it('fetches resolutions from GET /api/v1/settings/resolutions', async () => { const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }]; vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions)); diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts index e4d722c51..550e5f464 100644 --- a/web/src/api/settings.ts +++ b/web/src/api/settings.ts @@ -19,6 +19,8 @@ export type UiSettings = components['schemas']['UiSettingsResponseModel']; export type UpdateUiSettingsRequest = components['schemas']['UpdateUiSettingsRequest']; export type HdhrSettings = components['schemas']['HdhrSettingsResponseModel']; export type UpdateHdhrSettingsRequest = components['schemas']['UpdateHdhrSettingsRequest']; +export type IptvSettings = components['schemas']['IptvSettingsResponseModel']; +export type UpdateIptvSettingsRequest = components['schemas']['UpdateIptvSettingsRequest']; export type Resolution = components['schemas']['ResolutionResponseModel']; export type CreateResolutionRequest = components['schemas']['CreateResolutionRequest']; export type LogEventLevel = components['schemas']['LogEventLevel']; @@ -83,6 +85,14 @@ export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise('/api/v1/settings/hdhr', { body, method: 'PUT' }); } +export function getIptvSettings(): Promise { + return request('/api/v1/settings/iptv'); +} + +export function updateIptvSettings(body: UpdateIptvSettingsRequest): Promise { + return request('/api/v1/settings/iptv', { body, method: 'PUT' }); +} + export function getResolutions(): Promise { return request('/api/v1/settings/resolutions'); } @@ -103,6 +113,7 @@ export interface SettingsScreenData { logging: LoggingSettings; ui: UiSettings; hdhr: HdhrSettings; + iptv: IptvSettings; resolutions: Resolution[]; ffmpegProfiles: FFmpegProfile[]; watermarks: Watermark[]; @@ -158,6 +169,7 @@ async function loadSettingsScreenData(): Promise { getLoggingSettings(), getUiSettings(), getHdhrSettings(), + getIptvSettings(), getResolutions() ]); @@ -172,7 +184,7 @@ async function loadSettingsScreenData(): Promise { getDashboardHealth() ] as const); - const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, resolutions] = await tier1; + const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, iptv, resolutions] = await tier1; const [profilesResult, watermarksResult, fillerPresetsResult, mediaSourcesResult, versionResult, healthResult] = await tier2; @@ -189,6 +201,7 @@ async function loadSettingsScreenData(): Promise { fillerPresets: fillerPresets.value, health: health.value, hdhr, + iptv, logging, mediaSources: mediaSources.value, playout, diff --git a/web/src/screens/SettingsScreen.test.tsx b/web/src/screens/SettingsScreen.test.tsx index 01daf3c9e..416b7ca9b 100644 --- a/web/src/screens/SettingsScreen.test.tsx +++ b/web/src/screens/SettingsScreen.test.tsx @@ -101,6 +101,25 @@ describe('Settings screen (#93)', () => { await waitFor(() => expect(screen.queryByText('Settings saved')).not.toBeInTheDocument()); }); + it('renders the IPTV pane and saves an edited advertised base URL', async () => { + mockSettingsApi(); + await openSettings(); + fireEvent.click(await screen.findByRole('button', { name: /^IPTV/ })); + + expect( + await screen.findByText('How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs.') + ).toBeInTheDocument(); + + const baseUrlInput = screen.getByPlaceholderText('(use request origin)'); + fireEvent.change(baseUrlInput, { target: { value: 'https://tv.example.com/etv' } }); + + await screen.findByText('1 unsaved change'); + fireEvent.click(screen.getByRole('button', { name: /Save changes/ })); + + expect(await screen.findByText('Settings saved')).toBeInTheDocument(); + expect(requestBodyFor('/api/v1/settings/iptv')).toMatchObject({ baseUrl: 'https://tv.example.com/etv' }); + }); + it('shows a warning callout when scanner refresh interval is 0', async () => { mockSettingsApi({ scannerSettings: defaultScannerSettings({ libraryRefreshInterval: 0 }) }); await openSettings(); @@ -482,6 +501,13 @@ function defaultHdhrSettings(overrides: Record = {}): Record = {}): Record { + return { + baseUrl: '', + ...overrides + }; +} + function defaultResolutions(): Array> { return [ { height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }, @@ -493,6 +519,7 @@ function mockSettingsApi({ ffmpegProfiles = [], ffmpegSettings = defaultFfmpegSettings(), hdhrSettings = defaultHdhrSettings(), + iptvSettings = defaultIptvSettings(), loggingSettings = defaultLoggingSettings(), mediaSources = [], mediaSourcesFailuresBeforeSuccess = 0, @@ -510,6 +537,7 @@ function mockSettingsApi({ ffmpegProfiles?: unknown[]; ffmpegSettings?: Record; hdhrSettings?: Record; + iptvSettings?: Record; loggingSettings?: Record; mediaSources?: unknown[]; mediaSourcesFailuresBeforeSuccess?: number; @@ -530,6 +558,7 @@ function mockSettingsApi({ const currentSettings: Record> = { '/api/v1/settings/ffmpeg': ffmpegSettings, '/api/v1/settings/hdhr': hdhrSettings, + '/api/v1/settings/iptv': iptvSettings, '/api/v1/settings/logging': loggingSettings, '/api/v1/settings/playout': playoutSettings, '/api/v1/settings/scanner': scannerSettings, diff --git a/web/src/screens/SettingsScreen.tsx b/web/src/screens/SettingsScreen.tsx index 389bc0f81..8854c0b38 100644 --- a/web/src/screens/SettingsScreen.tsx +++ b/web/src/screens/SettingsScreen.tsx @@ -14,6 +14,7 @@ import { ListVideo, Proportions, Radar, + Radio, ScrollText, Server, SlidersHorizontal, @@ -28,6 +29,7 @@ import { messageFromSettingsError, updateFfmpegSettings, updateHdhrSettings, + updateIptvSettings, updateLoggingSettings, updatePlayoutSettings, updateScannerSettings, @@ -36,6 +38,7 @@ import { useSettingsScreenQuery, type FfmpegSettings, type HdhrSettings, + type IptvSettings, type LoggingSettings, type LogEventLevel, type MediaSource, @@ -52,7 +55,7 @@ import { const SETTINGS_BASE_PATH = '/app/settings'; -type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'scanner' | 'logging' | 'system'; +type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'iptv' | 'scanner' | 'logging' | 'system'; interface SectionDef { hint: string; @@ -66,6 +69,7 @@ const SECTIONS: SectionDef[] = [ { hint: 'FFmpeg & transcoding', icon: