diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 12219e5b8..1dfdfd51e 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -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 => diff --git a/ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs index 843c9cc6d..b098ef917 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelPreviewResponseModel.cs @@ -9,8 +9,10 @@ namespace ErsatzTV.Core.Api.Channels; /// SPA would mean keying behavior off the human-readable StreamingMode label. /// 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, diff --git a/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs b/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs index b4d95a3eb..7939fd5b7 100644 --- a/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs +++ b/ErsatzTV.Tests/Application/Channels/ChannelPreviewTests.cs @@ -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"); } diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 686e7eba1..c28e27d54 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -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() {