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>
52 lines
1.8 KiB
C#
52 lines
1.8 KiB
C#
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");
|
|
}
|
|
}
|