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
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:
@@ -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 &), 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 '&', 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<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);
|
||||
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)".
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user