diff --git a/ErsatzTV.Application/Channels/Mapper.cs b/ErsatzTV.Application/Channels/Mapper.cs index 558f9f061..3f1b5a75e 100644 --- a/ErsatzTV.Application/Channels/Mapper.cs +++ b/ErsatzTV.Application/Channels/Mapper.cs @@ -6,6 +6,29 @@ namespace ErsatzTV.Application.Channels; internal static class Mapper { + /// + /// A mirror channel has no playouts of its own; it relays the playouts of its mirror source, so both must be + /// counted for the total to answer "can this channel play anything?". Requires + /// and, for mirrors, . to be included + /// by the query — the repository reads are AsNoTracking, so an un-included navigation silently counts zero. + /// + internal static int GetPlayoutsCount(Channel channel) + { + var result = 0; + + if (channel.Playouts != null) + { + result += channel.Playouts.Count; + } + + if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null) + { + result += channel.MirrorSourceChannel.Playouts.Count; + } + + return result; + } + internal static ChannelViewModel ProjectToViewModel(Channel channel, int playoutCount) => new( channel.Id, @@ -73,7 +96,7 @@ internal static class Mapper channel.ShowInEpg); } - internal static ChannelResponseModel ProjectToResponseModel(Channel channel) => + internal static ChannelResponseModel ProjectToResponseModel(Channel channel, int playoutCount) => new( channel.Id, channel.Number, @@ -85,7 +108,8 @@ internal static class Mapper channel.PreferredAudioLanguageCode, GetStreamingMode(channel), channel.IsEnabled, - channel.ShowInEpg); + channel.ShowInEpg, + playoutCount); internal static ResolutionViewModel ProjectToViewModel(Resolution resolution) => new(resolution.Height, resolution.Width); diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs index c9f20b63f..d8fe82855 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsForApiHandler.cs @@ -13,6 +13,6 @@ public class GetAllChannelsForApiHandler(IChannelRepository channelRepository) CancellationToken cancellationToken) { IEnumerable channels = Optional(await channelRepository.GetAll(cancellationToken)).Flatten(); - return channels.Map(ProjectToResponseModel).ToList(); + return channels.Map(c => ProjectToResponseModel(c, GetPlayoutsCount(c))).ToList(); } } diff --git a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs index 66d02d669..52e0dcb3f 100644 --- a/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetAllChannelsHandler.cs @@ -11,21 +11,4 @@ public class GetAllChannelsHandler(IChannelRepository channelRepository) await channelRepository.GetAll(cancellationToken) .Map(list => list.Where(c => c.IsEnabled || request.ShowDisabled) .Map(c => ProjectToViewModel(c, GetPlayoutsCount(c))).ToList()); - - private static int GetPlayoutsCount(Channel channel) - { - var result = 0; - - if (channel.Playouts != null) - { - result += channel.Playouts.Count; - } - - if (channel.PlayoutSource is ChannelPlayoutSource.Mirror && channel.MirrorSourceChannel?.Playouts != null) - { - result += channel.MirrorSourceChannel.Playouts.Count; - } - - return result; - } } diff --git a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs index 8773964db..065b695e6 100644 --- a/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs +++ b/ErsatzTV.Application/Channels/Queries/GetChannelByIdForApiHandler.cs @@ -11,5 +11,5 @@ public class GetChannelByIdForApiHandler(IChannelRepository channelRepository) GetChannelByIdForApi request, CancellationToken cancellationToken) => channelRepository.GetChannel(request.Id) - .MapT(channel => ProjectToDetailResponseModel(channel, channel.Playouts?.Count ?? 0)); + .MapT(channel => ProjectToDetailResponseModel(channel, GetPlayoutsCount(channel))); } diff --git a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs index 55ceab9d8..4d284dd97 100644 --- a/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs +++ b/ErsatzTV.Core/Api/Channels/ChannelResponseModel.cs @@ -15,4 +15,5 @@ public record ChannelResponseModel( string Language, string StreamingMode, bool IsEnabled, - bool ShowInEpg); + bool ShowInEpg, + int PlayoutCount); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs index 79ce5d889..4bf371005 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/ChannelRepository.cs @@ -14,6 +14,9 @@ public class ChannelRepository(IDbContextFactory dbContextFactory) : .Include(c => c.FFmpegProfile) .Include(c => c.Artwork) .Include(c => c.Watermark) + .Include(c => c.Playouts) + .Include(c => c.MirrorSourceChannel) + .ThenInclude(mc => mc.Playouts) .OrderBy(c => c.Id) .SingleOrDefaultAsync(c => c.Id == id) .Map(Optional); diff --git a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs index 1388a65ae..12788ea0d 100644 --- a/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs +++ b/ErsatzTV.Tests/Controllers/OpenApiSerializerContractTests.cs @@ -151,7 +151,8 @@ public class OpenApiSerializerContractTests "en", "TransportStream", true, - true); + true, + 2); private static string FindOpenApiDocument() { diff --git a/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs new file mode 100644 index 000000000..c4c247681 --- /dev/null +++ b/ErsatzTV.Tests/Infrastructure/ChannelRepositoryPlayoutIncludeTests.cs @@ -0,0 +1,140 @@ +using ErsatzTV.Application.Channels; +using ErsatzTV.Core.Api.Channels; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Data.Repositories; +using ErsatzTV.Tests.Support; +using LanguageExt; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Infrastructure; + +// #72: the channels API reports a per-channel playout count so the SPA can flag a channel that will +// never play. GetChannel omitted the Playouts include, and the read is AsNoTracking with no lazy-loading +// proxies, so the navigation came back empty and every caller's count collapsed to 0 — GET +// /api/v1/channels/{id} reported playoutCount: 0 for every channel on the system. That silently disabled +// the channel editor's playout-source guard (ChannelEditScreen "Cannot be changed once a generated +// channel has a playout"), which is gated on playoutCount > 0. +// +// These run the REAL repository against a real context on purpose: a handler test with a substituted +// IChannelRepository hands the mapper a channel whose Playouts the test itself populated, so it passes +// whether or not the query includes them. Only a repository-level read can catch a missing include. +[TestFixture] +public class ChannelRepositoryPlayoutIncludeTests +{ + [SetUp] + public async Task SetUp() + { + _db = await InMemoryTvContext.CreateAsync(); + _repository = new ChannelRepository(_db.Factory); + } + + [TearDown] + public async Task TearDown() => await _db.DisposeAsync(); + + private InMemoryTvContext _db = null!; + private ChannelRepository _repository = null!; + + // FFmpegProfileId is non-nullable, so GetChannel's Include(c => c.FFmpegProfile) is an INNER JOIN — + // a channel with no profile row is filtered out of the result entirely. Every fixture needs a real one. + private async Task SeedFFmpegProfile() + { + var profile = new FFmpegProfile { Name = "Test Profile" }; + await using TvContext context = _db.CreateContext(); + context.FFmpegProfiles.Add(profile); + await context.SaveChangesAsync(); + return profile.Id; + } + + private async Task SeedChannel(Channel channel) + { + await using TvContext context = _db.CreateContext(); + context.Channels.Add(channel); + await context.SaveChangesAsync(); + return channel.Id; + } + + private async Task GetDetail(int channelId) + { + var handler = new GetChannelByIdForApiHandler(_repository); + Option result = + await handler.Handle(new GetChannelByIdForApi(channelId), CancellationToken.None); + return result.IfNone(() => throw new InvalidOperationException($"channel {channelId} not found")); + } + + [Test] + public async Task GetChannel_Should_Include_Playouts_So_The_Detail_Api_Reports_A_Real_Count() + { + int profileId = await SeedFFmpegProfile(); + int channelId = await SeedChannel( + new Channel(Guid.NewGuid()) + { + Number = "1", + Name = "Generated", + PlayoutSource = ChannelPlayoutSource.Generated, + FFmpegProfileId = profileId, + Artwork = [], + Playouts = [new Playout(), new Playout()] + }); + + ChannelDetailResponseModel detail = await GetDetail(channelId); + + detail.PlayoutCount.ShouldBe(2); + } + + [Test] + public async Task GetChannel_Should_Count_The_Mirror_Sources_Playouts_For_A_Mirror_Channel() + { + // A mirror channel owns no playouts; it relays its source's. Counting only its own navigation + // would report 0 and wrongly flag a working mirror channel as "will never play". + int profileId = await SeedFFmpegProfile(); + int sourceId = await SeedChannel( + new Channel(Guid.NewGuid()) + { + Number = "1", + Name = "Source", + PlayoutSource = ChannelPlayoutSource.Generated, + FFmpegProfileId = profileId, + Artwork = [], + Playouts = [new Playout()] + }); + + int mirrorId = await SeedChannel( + new Channel(Guid.NewGuid()) + { + Number = "2", + Name = "Mirror", + PlayoutSource = ChannelPlayoutSource.Mirror, + MirrorSourceChannelId = sourceId, + FFmpegProfileId = profileId, + Artwork = [], + Playouts = [] + }); + + ChannelDetailResponseModel detail = await GetDetail(mirrorId); + + detail.PlayoutCount.ShouldBe(1); + } + + [Test] + public async Task GetChannel_Should_Report_Zero_For_A_Channel_With_No_Playouts() + { + // The "will never play" signal #72 renders — must stay 0 rather than becoming vacuously non-zero. + int profileId = await SeedFFmpegProfile(); + int channelId = await SeedChannel( + new Channel(Guid.NewGuid()) + { + Number = "3", + Name = "Empty", + PlayoutSource = ChannelPlayoutSource.Generated, + FFmpegProfileId = profileId, + Artwork = [], + Playouts = [] + }); + + ChannelDetailResponseModel detail = await GetDetail(channelId); + + detail.PlayoutCount.ShouldBe(0); + } +} diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index f5226eaad..097ca0181 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -23763,7 +23763,8 @@ "language", "streamingMode", "isEnabled", - "showInEpg" + "showInEpg", + "playoutCount" ], "type": "object", "properties": { @@ -23801,6 +23802,10 @@ }, "showInEpg": { "type": "boolean" + }, + "playoutCount": { + "type": "integer", + "format": "int32" } } }, diff --git a/web/src/api/generated/v1.d.ts b/web/src/api/generated/v1.d.ts index 377b15581..52ade2f22 100644 --- a/web/src/api/generated/v1.d.ts +++ b/web/src/api/generated/v1.d.ts @@ -239,6 +239,7 @@ export interface components { "streamingMode": string; "isEnabled": boolean; "showInEpg": boolean; + "playoutCount": number; }; "ChannelSongVideoMode": "Default" | "WithProgress"; "ChannelStateResponseModel": {