Files
ersatztv/ErsatzTV.Core.Tests/Iptv/GetChannelPlaylistHandlerTests.cs
T
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00

72 lines
2.4 KiB
C#

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
}
};
}