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>
72 lines
2.4 KiB
C#
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
|
|
}
|
|
};
|
|
}
|