fix(60): declare disabled-channel and no-playout as Unavailable preview causes

GetPreview keyed only on StreamingMode + JWT, so a disabled channel or one
with no playout was declared Available and then failed confusingly (404 from
IptvController, or an indefinitely-blocking manifest request). Extend it to
take isEnabled + playoutCount and check JWT, then disabled, then no-playout,
before falling back to the existing mode-based rules; order is documented
in a comment. Also corrects a stale comment in ChannelPreviewResponseModel.cs
that claimed a C# string generates a TypeScript union (it does not).
This commit is contained in:
2026-07-21 23:13:27 +02:00
parent 26330cf04c
commit 6000356739
4 changed files with 74 additions and 10 deletions
+27 -2
View File
@@ -114,7 +114,7 @@ internal static class Mapper
channel.ShowInEpg,
playoutCount,
GetLogoUrl(channel),
GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled));
GetPreview(channel.StreamingMode, channel.Number, iptvJwtEnabled, channel.IsEnabled, playoutCount));
internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) =>
new(resolution.Height, resolution.Width);
@@ -183,8 +183,17 @@ internal static class Mapper
internal static ChannelPreviewResponseModel GetPreview(
StreamingMode streamingMode,
string channelNumber,
bool iptvJwtEnabled)
bool iptvJwtEnabled,
bool isEnabled,
int playoutCount)
{
// Precedence among the three Unavailable causes (checked in this order; the first match wins):
// 1. JWT enabled — a host-wide auth configuration; nothing the operator can fix per-channel.
// 2. channel disabled — an explicit operator choice; IptvController 404s a disabled channel, so
// preview must not even try.
// 3. no playout — the channel could theoretically play once scheduled, but a manifest
// request against it blocks indefinitely today; catch it before that happens.
//
// /iptv/* is gated by ConditionalIptvAuthorizeFilter only when JWT is configured, and the
// "jwt" scheme does not accept the SPA's ctv-session cookie. Nothing mints a JWT for the
// SPA today, so preview cannot run at all in that configuration.
@@ -196,6 +205,22 @@ internal static class Mapper
"IPTV JWT authentication is enabled");
}
if (!isEnabled)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel is disabled");
}
if (playoutCount == 0)
{
return new ChannelPreviewResponseModel(
ChannelPreviewAvailability.Unavailable,
null,
"Channel has no playout");
}
return streamingMode switch
{
StreamingMode.HttpLiveStreamingSegmenter or StreamingMode.HttpLiveStreamingDirect =>
@@ -9,8 +9,10 @@ namespace ErsatzTV.Core.Api.Channels;
/// SPA would mean keying behavior off the human-readable StreamingMode label.
/// </remarks>
public record ChannelPreviewResponseModel(
// One of ChannelPreviewAvailability's values. A plain string (not an enum) so the generated
// TypeScript is a string union the SPA can switch on directly.
// One of ChannelPreviewAvailability's values. A plain string (not a C# enum), which keeps the
// OpenAPI schema simple, but the tradeoff is that the generated TypeScript types this as a bare
// `string`, not a literal union — the SPA hand-maintains its own `ChannelPreviewAvailability`
// union (web/src/api/channels.ts) and narrows against it, rather than getting one for free.
string Availability,
// Rooted, directly-usable HLS manifest URL; null when Availability is Unavailable.
string? ManifestUrl,
@@ -13,7 +13,8 @@ public class ChannelPreviewTests
[TestCase(StreamingMode.HttpLiveStreamingDirect)]
public void HlsModes_Are_Available_With_Plain_Manifest_Url(StreamingMode mode)
{
ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false);
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("Available");
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8");
@@ -24,7 +25,8 @@ public class ChannelPreviewTests
[TestCase(StreamingMode.TransportStreamHybrid)]
public void TransportStream_Modes_Are_ForcedHlsOnly(StreamingMode mode)
{
ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false);
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: false, isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("ForcedHlsOnly");
result.ManifestUrl.ShouldBe("/iptv/channel/12.1.m3u8?mode=segmenter");
@@ -37,18 +39,53 @@ public class ChannelPreviewTests
[TestCase(StreamingMode.TransportStreamHybrid)]
public void Jwt_Enabled_Makes_Every_Mode_Unavailable(StreamingMode mode)
{
ChannelPreviewResponseModel result = Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true);
ChannelPreviewResponseModel result =
Mapper.GetPreview(mode, "12.1", iptvJwtEnabled: true, isEnabled: true, playoutCount: 1);
result.Availability.ShouldBe("Unavailable");
result.ManifestUrl.ShouldBeNull();
result.UnavailableReason.ShouldBe("IPTV JWT authentication is enabled");
}
[Test]
public void Disabled_Channel_Is_Unavailable()
{
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: false,
isEnabled: false,
playoutCount: 1);
result.Availability.ShouldBe("Unavailable");
result.ManifestUrl.ShouldBeNull();
result.UnavailableReason.ShouldBe("Channel is disabled");
}
[Test]
public void Zero_Playout_Count_Is_Unavailable()
{
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"12.1",
iptvJwtEnabled: false,
isEnabled: true,
playoutCount: 0);
result.Availability.ShouldBe("Unavailable");
result.ManifestUrl.ShouldBeNull();
result.UnavailableReason.ShouldBe("Channel has no playout");
}
[Test]
public void Channel_Number_Is_Used_Verbatim_In_The_Manifest_Url()
{
ChannelPreviewResponseModel result =
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "7", iptvJwtEnabled: false);
ChannelPreviewResponseModel result = Mapper.GetPreview(
StreamingMode.HttpLiveStreamingSegmenter,
"7",
iptvJwtEnabled: false,
isEnabled: true,
playoutCount: 1);
result.ManifestUrl.ShouldBe("/iptv/channel/7.m3u8");
}
@@ -156,7 +156,7 @@ public class OpenApiSerializerContractTests
true,
2,
"/iptv/logos/logo.png",
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false));
Mapper.GetPreview(StreamingMode.HttpLiveStreamingSegmenter, "1", false, true, 2));
private static string FindOpenApiDocument()
{