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": {