Merge pull request '#340: configurable advertised IPTV base URL for M3U/XMLTV' (#374)
Build ErsatzTV Image / decisions.md append-only (push) Has been skipped
Build ErsatzTV Image / Docs update reminder (push) Has been skipped
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Has been skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Has been skipped
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 4m3s
Build ErsatzTV Image / Functional E2E (curl contracts) (push) Successful in 3m34s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 9m0s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 6m44s

This commit was merged in pull request #374.
This commit is contained in:
2026-07-16 19:51:27 +00:00
28 changed files with 1062 additions and 27 deletions
@@ -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<TvContext> dbContextFactory,
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
IFileSystem fileSystem,
ILocalFileSystem localFileSystem)
ILocalFileSystem localFileSystem,
IConfigElementRepository configElementRepository)
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
{
public async Task<Either<BaseError, ChannelGuide>> Handle(
@@ -23,6 +27,21 @@ public partial class GetChannelGuideHandler(
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(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 &amp;), 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<string, string>();
@@ -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);
@@ -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<GetChannelPlaylist, ChannelPlaylist>
{
public Task<ChannelPlaylist> 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<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
{
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
maybeBaseUrl.IfNone(string.Empty),
request.Scheme,
request.Host,
request.BaseUrl);
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
return new ChannelPlaylist(
scheme,
host,
baseUrl,
channels,
request.UserAgent,
request.AccessToken);
}
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
{
@@ -0,0 +1,5 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Configuration;
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
@@ -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<UpdateIptvSettings, Either<BaseError, Unit>>
{
public async Task<Either<BaseError, Unit>> Handle(
UpdateIptvSettings request,
CancellationToken cancellationToken)
{
Validation<BaseError, Unit> validation = Validate(request);
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
}
private async Task<Unit> 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<BaseError, Unit> 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<BaseError>(
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
}
}
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application.Configuration;
public class IptvSettingsViewModel
{
public string BaseUrl { get; set; }
}
@@ -0,0 +1,3 @@
namespace ErsatzTV.Application.Configuration;
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
@@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Configuration;
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
{
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
{
Option<string> maybeBaseUrl =
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
return new IptvSettingsViewModel
{
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
};
}
}
@@ -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();
}
@@ -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<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://public.example.com/etv"));
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> 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 <icon> and the programme <icon>.
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<ILocalFileSystem>();
localFileSystem
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
.Returns(new[]
{
FragmentPath(fileSystem, "channels.xml"),
FragmentPath(fileSystem, "2.xml")
});
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> 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 '&amp;', never a bare '&'.
xml.ShouldContain("https://tv.example.com/a&amp;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<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetChannelGuideHandler(
_dbContextFactory,
new RecyclableMemoryStreamManager(),
fileSystem,
localFileSystem);
localFileSystem,
configElementRepository);
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
@@ -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<string> BuildM3U(string configuredBaseUrl)
{
var channelRepository = Substitute.For<IChannelRepository>();
channelRepository.GetAll(Arg.Any<CancellationToken>()).Returns([BuildChannel()]);
var configElementRepository = Substitute.For<IConfigElementRepository>();
configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.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
}
};
}
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Settings;
#nullable enable
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path".
/// </summary>
public record IptvSettingsResponseModel(string BaseUrl);
+5
View File
@@ -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.
+67
View File
@@ -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);
}
}
@@ -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<IConfigElementRepository>();
[Test]
public async Task Get_Returns_Empty_When_Unset()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.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<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.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<BaseError, Unit> 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<ConfigElementKey>(k => k.Key == Key),
"https://public.example.com/etv",
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public async Task Update_Clears_Setting_When_Blank(string blank)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Delete(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[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<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
}
@@ -242,6 +242,48 @@ public class SettingsControllerTests
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetIptv_Should_Map_Vm_To_Response_Model()
{
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.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<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Right<BaseError, Unit>(Unit.Default));
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" });
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("https://public.example.com"),
CancellationToken.None);
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com"));
await _mediator.Received(1).Send(
Arg.Is<UpdateIptvSettings>(c => c.IptvSettings.BaseUrl == "https://public.example.com"),
Arg.Any<CancellationToken>());
}
[Test]
public async Task UpdateIptv_Should_Return_422_On_Validation_Error()
{
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
IActionResult result = await _controller.UpdateIptv(
new UpdateIptvSettingsRequest("not a url"),
CancellationToken.None);
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
}
[Test]
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
{
@@ -0,0 +1,13 @@
using ErsatzTV.Application.Configuration;
namespace ErsatzTV.Controllers.Api.Requests;
/// <summary>
/// IPTV output settings. <see cref="BaseUrl" /> 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.
/// </summary>
public record UpdateIptvSettingsRequest(string BaseUrl)
{
public UpdateIptvSettings ToCommand() =>
new(new IptvSettingsViewModel { BaseUrl = BaseUrl });
}
@@ -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<IptvSettingsResponseModel> 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<IActionResult> UpdateIptv(
[Required] [FromBody]
UpdateIptvSettingsRequest request,
CancellationToken cancellationToken)
{
Either<BaseError, Unit> 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));
+176
View File
@@ -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",
+47
View File
@@ -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 `<icon>`/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)".
+3 -1
View File
@@ -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`.
+3 -1
View File
@@ -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 |
+19 -7
View File
@@ -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<T>` / 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<T>` / 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)
+8
View File
@@ -766,6 +766,13 @@ function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<st
};
}
function defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
baseUrl: '',
...overrides
};
}
function defaultResolutions(): Array<Record<string, unknown>> {
return [
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
@@ -952,6 +959,7 @@ function mockDashboardApi({
const settingsResponses: Record<string, unknown> = {
'/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(),
+6
View File
@@ -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;
+16
View File
@@ -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));
+14 -1
View File
@@ -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<Hdh
return request<HdhrSettings>('/api/v1/settings/hdhr', { body, method: 'PUT' });
}
export function getIptvSettings(): Promise<IptvSettings> {
return request<IptvSettings>('/api/v1/settings/iptv');
}
export function updateIptvSettings(body: UpdateIptvSettingsRequest): Promise<IptvSettings> {
return request<IptvSettings>('/api/v1/settings/iptv', { body, method: 'PUT' });
}
export function getResolutions(): Promise<Resolution[]> {
return request<Resolution[]>('/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<SettingsScreenData> {
getLoggingSettings(),
getUiSettings(),
getHdhrSettings(),
getIptvSettings(),
getResolutions()
]);
@@ -172,7 +184,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
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<SettingsScreenData> {
fillerPresets: fillerPresets.value,
health: health.value,
hdhr,
iptv,
logging,
mediaSources: mediaSources.value,
playout,
+29
View File
@@ -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<string, unknown> = {}): Record<st
};
}
function defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
baseUrl: '',
...overrides
};
}
function defaultResolutions(): Array<Record<string, unknown>> {
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<string, unknown>;
hdhrSettings?: Record<string, unknown>;
iptvSettings?: Record<string, unknown>;
loggingSettings?: Record<string, unknown>;
mediaSources?: unknown[];
mediaSourcesFailuresBeforeSuccess?: number;
@@ -530,6 +558,7 @@ function mockSettingsApi({
const currentSettings: Record<string, Record<string, unknown>> = {
'/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,
+42 -2
View File
@@ -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: <Clapperboard aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
{ hint: 'Build defaults', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
{ hint: 'EPG output', icon: <CalendarDays aria-hidden="true" size={16} />, id: 'xmltv', label: 'Guide (XMLTV)' },
{ hint: 'M3U & stream URLs', icon: <Radio aria-hidden="true" size={16} />, id: 'iptv', label: 'IPTV' },
{ hint: 'Library refresh', icon: <Radar aria-hidden="true" size={16} />, id: 'scanner', label: 'Scanner' },
{ hint: 'Log levels', icon: <ScrollText aria-hidden="true" size={16} />, id: 'logging', label: 'Logging' },
{ hint: 'HDHR, sources, about', icon: <Server aria-hidden="true" size={16} />, id: 'system', label: 'System' }
@@ -115,6 +119,7 @@ function hlsDirectFormatOptions(current: OutputFormatKind): Array<{ label: strin
interface Draft {
ffmpeg: FfmpegSettings;
hdhr: HdhrSettings;
iptv: IptvSettings;
logging: LoggingSettings;
playout: PlayoutSettings;
scanner: ScannerSettings;
@@ -124,12 +129,13 @@ interface Draft {
type GroupKey = keyof Draft;
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'scanner', 'logging', 'hdhr'];
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'iptv', 'scanner', 'logging', 'hdhr'];
function draftFromData(data: SettingsScreenData): Draft {
return {
ffmpeg: data.ffmpeg,
hdhr: data.hdhr,
iptv: data.iptv,
logging: data.logging,
playout: data.playout,
scanner: data.scanner,
@@ -172,6 +178,7 @@ function formatLastScan(value: string): string {
const GROUP_LABELS: Record<GroupKey, string> = {
ffmpeg: 'Streaming',
hdhr: 'System',
iptv: 'IPTV',
logging: 'Logging',
playout: 'Playout',
scanner: 'Scanner',
@@ -189,6 +196,8 @@ async function saveGroup(key: GroupKey, draft: Draft): Promise<Partial<Draft>> {
return { playout: await updatePlayoutSettings(draft.playout) };
case 'xmltv':
return { xmltv: await updateXmltvSettings(draft.xmltv) };
case 'iptv':
return { iptv: await updateIptvSettings(draft.iptv) };
case 'scanner':
return { scanner: await updateScannerSettings(draft.scanner) };
case 'logging':
@@ -703,6 +712,36 @@ function XmltvPane({
);
}
function IptvPane({
iptv,
set
}: {
iptv: IptvSettings;
set: (patch: Partial<IptvSettings>) => void;
}) {
return (
<Pane subtitle="How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs." title="IPTV">
<Card padded={false}>
<Row
control={340}
first
help="Absolute base URL clients should use to reach this server (e.g. http://192.168.1.99:8409 or https://tv.example.com/etv). Leave empty to use the address of each incoming request."
label="Advertised base URL"
>
<Input
fullWidth
onChange={(event) => set({ baseUrl: event.target.value })}
placeholder="(use request origin)"
size="sm"
style={{ fontFamily: 'var(--font-mono)' }}
value={iptv.baseUrl}
/>
</Row>
</Card>
</Pane>
);
}
function ScannerPane({
invalidFields,
scanner,
@@ -1104,6 +1143,7 @@ export function SettingsScreen() {
{section === 'xmltv' && (
<XmltvPane invalidFields={invalidFields} set={(patch) => set('xmltv', patch)} xmltv={draft.xmltv} />
)}
{section === 'iptv' && <IptvPane iptv={draft.iptv} set={(patch) => set('iptv', patch)} />}
{section === 'scanner' && (
<ScannerPane invalidFields={invalidFields} scanner={draft.scanner} set={(patch) => set('scanner', patch)} />
)}