Merge pull request 'feat(api): channel on-air / now-playing state endpoint (#97)' (#112) from issue-97-channel-state-api into docs/59-ui-redesign-brief

This commit was merged in pull request #112.
This commit is contained in:
2026-07-04 05:48:46 +00:00
11 changed files with 693 additions and 0 deletions
@@ -0,0 +1,5 @@
using ErsatzTV.Core.Api.Channels;
namespace ErsatzTV.Application.Channels;
public record GetChannelStatesForApi(DateTime Now) : IRequest<List<ChannelStateResponseModel>>;
@@ -0,0 +1,151 @@
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
namespace ErsatzTV.Application.Channels;
public class GetChannelStatesForApiHandler(
IDbContextFactory<TvContext> dbContextFactory,
IFFmpegSegmenterService ffmpegSegmenterService)
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
{
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
// bound also protects against GuideGroup values recycling (mod 10000) elsewhere in a playout
private static readonly TimeSpan GuideEntryBound = TimeSpan.FromDays(1);
public async Task<List<ChannelStateResponseModel>> Handle(
GetChannelStatesForApi request,
CancellationToken cancellationToken)
{
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
List<Channel> channels = await dbContext.Channels
.AsNoTracking()
.OrderBy(c => c.SortNumber)
.ThenBy(c => c.Number)
.ToListAsync(cancellationToken);
if (channels.Count == 0)
{
return [];
}
Dictionary<int, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> channelLookup = channels
.ToDictionary(
c => c.Id,
c =>
{
TimeSpan offset = c.PlayoutOffset ?? TimeSpan.Zero;
return (c.MirrorSourceChannelId ?? c.Id, offset, request.Now - offset);
});
// one covering-item query per distinct lookup time (i.e. per distinct playout offset,
// typically just one) with a cheap projection; metadata is hydrated in a second query
// for only the covering items' guide groups
var coveringBySourceAndTime = new Dictionary<(int SourceChannelId, DateTime LookupTime), CoveringItem>();
foreach (IGrouping<DateTime, (int SourceChannelId, TimeSpan Offset, DateTime LookupTime)> timeGroup in
channelLookup.Values.GroupBy(v => v.LookupTime))
{
DateTime lookupTime = timeGroup.Key;
int[] sourceChannelIds = timeGroup.Map(v => v.SourceChannelId).Distinct().ToArray();
List<CoveringItem> covering = await dbContext.PlayoutItems
.AsNoTracking()
.Where(pi => sourceChannelIds.Contains(pi.Playout.ChannelId))
.Where(pi => pi.Start <= lookupTime && pi.Finish > lookupTime)
.Select(pi => new CoveringItem(pi.Id, pi.PlayoutId, pi.Playout.ChannelId, pi.GuideGroup, pi.Start))
.ToListAsync(cancellationToken);
foreach (CoveringItem item in covering.OrderBy(ci => ci.Start))
{
coveringBySourceAndTime.TryAdd((item.ChannelId, lookupTime), item);
}
}
Dictionary<(int PlayoutId, int GuideGroup), List<PlayoutItem>> itemsByGuideEntry = [];
if (coveringBySourceAndTime.Count > 0)
{
int[] playoutIds = coveringBySourceAndTime.Values.Map(ci => ci.PlayoutId).Distinct().ToArray();
int[] guideGroups = coveringBySourceAndTime.Values.Map(ci => ci.GuideGroup).Distinct().ToArray();
DateTime windowStart = channelLookup.Values.Min(v => v.LookupTime) - GuideEntryBound;
DateTime windowFinish = channelLookup.Values.Max(v => v.LookupTime) + GuideEntryBound;
List<PlayoutItem> guideEntryItems = await dbContext.PlayoutItems
.AsNoTracking()
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).EpisodeMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Episode).Season)
.ThenInclude(s => s.Show)
.ThenInclude(s => s.ShowMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Movie).MovieMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as MusicVideo).Artist)
.ThenInclude(a => a.ArtistMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as OtherVideo).OtherVideoMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Song).SongMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as Image).ImageMetadata)
.Include(pi => pi.MediaItem)
.ThenInclude(mi => (mi as RemoteStream).RemoteStreamMetadata)
.Where(pi => playoutIds.Contains(pi.PlayoutId) && guideGroups.Contains(pi.GuideGroup))
.Where(pi => pi.Start <= windowFinish && pi.Finish > windowStart)
.ToListAsync(cancellationToken);
itemsByGuideEntry = guideEntryItems
.GroupBy(pi => (pi.PlayoutId, pi.GuideGroup))
.ToDictionary(g => g.Key, g => g.OrderBy(pi => pi.Start).ToList());
}
return channels
.Map(channel =>
{
(int sourceChannelId, TimeSpan offset, DateTime lookupTime) = channelLookup[channel.Id];
ChannelNowPlayingResponseModel nowPlaying = null;
if (coveringBySourceAndTime.TryGetValue((sourceChannelId, lookupTime), out CoveringItem covering) &&
itemsByGuideEntry.TryGetValue(
(covering.PlayoutId, covering.GuideGroup),
out List<PlayoutItem> guideEntry))
{
// like the XMLTV guide, surface the program rather than its filler: use the
// covering item itself when it is a program part, otherwise the guide entry's
// first program item; an entry with no program item (e.g. offline fallback
// filler) stays null
PlayoutItem coveringItem = guideEntry.Find(pi => pi.Id == covering.Id);
PlayoutItem displayItem = coveringItem?.FillerKind == FillerKind.None
? coveringItem
: guideEntry.Find(pi => pi.FillerKind == FillerKind.None);
if (displayItem is not null)
{
DateTime start = guideEntry[0].Start;
DateTime finish = displayItem.GuideFinish ?? guideEntry.Max(pi => pi.Finish);
nowPlaying = new ChannelNowPlayingResponseModel(
PlayoutMapper.GetDisplayTitle(displayItem.MediaItem, Optional(displayItem.ChapterTitle)),
new DateTimeOffset(start + offset, TimeSpan.Zero),
new DateTimeOffset(finish + offset, TimeSpan.Zero));
}
}
return new ChannelStateResponseModel(
channel.Id,
channel.Number,
ffmpegSegmenterService.IsActive(channel.Number),
nowPlaying);
})
.ToList();
}
private sealed record CoveringItem(int Id, int PlayoutId, int ChannelId, int GuideGroup, DateTime Start);
}
@@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelNowPlayingResponseModel(
string Title,
DateTimeOffset StartUtc,
DateTimeOffset FinishUtc);
@@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Api.Channels;
#nullable enable
public record ChannelStateResponseModel(
int ChannelId,
string ChannelNumber,
bool OnAir,
ChannelNowPlayingResponseModel? NowPlaying);
@@ -0,0 +1,329 @@
using ErsatzTV.Application.Channels;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Tests.Support;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
namespace ErsatzTV.Tests.Application.Channels;
[TestFixture]
public class GetChannelStatesForApiHandlerTests
{
private static readonly DateTime Now = new(2026, 7, 2, 12, 0, 0, DateTimeKind.Utc);
private InMemoryTvContext _db = null!;
private IFFmpegSegmenterService _segmenter = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_segmenter = Substitute.For<IFFmpegSegmenterService>();
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
[Test]
public async Task Handle_Should_Return_OnAir_And_Current_NowPlaying()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await SeedChannelWithMovie(start, finish);
_segmenter.IsActive("7.1").Returns(true);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.ChannelId.ShouldBe(7);
state.ChannelNumber.ShouldBe("7.1");
state.OnAir.ShouldBeTrue();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Retro Cartoons");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(start, TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
{
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldNotBeNull();
}
[Test]
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
{
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_When_No_Current_Item()
{
await using TvContext context = _db.CreateContext();
context.Channels.Add(MakeChannel(8, "8"));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.OnAir.ShouldBeFalse();
state.NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Surface_Program_Not_Filler_During_MidRoll_Break()
{
// program part 1 / mid-roll filler / program part 2, all in one guide group
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(12, "12");
var movie = new Movie
{
Id = 120,
MovieMetadata = [new MovieMetadata { Title = "Feature Presentation" }]
};
var filler = new OtherVideo
{
Id = 121,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Some Bumper" }]
};
var playout = new Playout { Id = 122, Channel = channel, ChannelId = channel.Id, Items = [] };
PlayoutItem MakeItem(int id, MediaItem mediaItem, DateTime start, DateTime finish, FillerKind fillerKind) =>
new()
{
Id = id,
MediaItem = mediaItem,
MediaItemId = mediaItem.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
FillerKind = fillerKind,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.AddRange(
MakeItem(123, movie, Now.AddMinutes(-20), Now.AddMinutes(-2), FillerKind.None),
MakeItem(124, filler, Now.AddMinutes(-2), Now.AddMinutes(2), FillerKind.MidRoll),
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel state = result.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Feature Presentation");
state.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(-20), TimeSpan.Zero));
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(Now.AddMinutes(40), TimeSpan.Zero));
}
[Test]
public async Task Handle_Should_Return_Null_NowPlaying_For_Guide_Entry_With_Only_Filler()
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(13, "13");
var filler = new OtherVideo
{
Id = 130,
OtherVideoMetadata = [new OtherVideoMetadata { Title = "Offline Loop" }]
};
var playout = new Playout { Id = 131, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 132,
MediaItem = filler,
MediaItemId = filler.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = Now.AddMinutes(-5),
Finish = Now.AddMinutes(5),
FillerKind = FillerKind.Fallback,
GuideGroup = 1,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.OtherVideos.Add(filler);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
result.ShouldHaveSingleItem().NowPlaying.ShouldBeNull();
}
[Test]
public async Task Handle_Should_Map_Remote_Stream_NowPlaying_Title()
{
DateTime start = Now.AddMinutes(-10);
DateTime finish = Now.AddMinutes(20);
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(9, "9");
var remoteStream = new RemoteStream
{
Id = 90,
RemoteStreamMetadata = [new RemoteStreamMetadata { Title = "Live Source" }]
};
var playout = new Playout { Id = 91, Channel = channel, ChannelId = channel.Id, Items = [] };
var item = new PlayoutItem
{
Id = 92,
MediaItem = remoteStream,
MediaItemId = remoteStream.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.RemoteStreams.Add(remoteStream);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
ChannelStateResponseModel state =
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
.ShouldHaveSingleItem();
state.NowPlaying.ShouldNotBeNull();
state.NowPlaying.Title.ShouldBe("Live Source");
}
[Test]
public async Task Handle_Should_Resolve_Mirror_Channel_NowPlaying_From_Source_With_Offset()
{
TimeSpan offset = TimeSpan.FromHours(1);
DateTime sourceStart = Now.AddMinutes(-70);
DateTime sourceFinish = Now.AddMinutes(-40);
await using TvContext context = _db.CreateContext();
Channel source = MakeChannel(10, "10");
Channel mirror = MakeChannel(11, "11");
mirror.PlayoutSource = ChannelPlayoutSource.Mirror;
mirror.MirrorSourceChannelId = source.Id;
mirror.PlayoutOffset = offset;
var movie = new Movie
{
Id = 110,
MovieMetadata = [new MovieMetadata { Title = "Offset Feature" }]
};
var playout = new Playout { Id = 111, Channel = source, ChannelId = source.Id, Items = [] };
var item = new PlayoutItem
{
Id = 112,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = sourceStart,
Finish = sourceFinish,
ChapterTitle = string.Empty
};
context.Channels.AddRange(source, mirror);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
List<ChannelStateResponseModel> result =
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
ChannelStateResponseModel mirrorState = result.Single(s => s.ChannelId == mirror.Id);
mirrorState.NowPlaying.ShouldNotBeNull();
mirrorState.NowPlaying.Title.ShouldBe("Offset Feature");
mirrorState.NowPlaying.StartUtc.ShouldBe(new DateTimeOffset(sourceStart + offset, TimeSpan.Zero));
mirrorState.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(sourceFinish + offset, TimeSpan.Zero));
// the source's own item is 40+ minutes in the past with no offset, so its own row is off-air
ChannelStateResponseModel sourceState = result.Single(s => s.ChannelId == source.Id);
sourceState.NowPlaying.ShouldBeNull();
}
private async Task SeedChannelWithMovie(DateTime start, DateTime finish)
{
await using TvContext context = _db.CreateContext();
Channel channel = MakeChannel(7, "7.1");
var movie = new Movie
{
Id = 10,
MovieMetadata = [new MovieMetadata { Title = "Retro Cartoons" }]
};
var playout = new Playout
{
Id = 20,
Channel = channel,
ChannelId = channel.Id,
Items = []
};
var item = new PlayoutItem
{
Id = 30,
MediaItem = movie,
MediaItemId = movie.Id,
Playout = playout,
PlayoutId = playout.Id,
Start = start,
Finish = finish,
ChapterTitle = string.Empty
};
context.Channels.Add(channel);
context.Movies.Add(movie);
context.Playouts.Add(playout);
context.PlayoutItems.Add(item);
await context.SaveChangesAsync();
}
private static Channel MakeChannel(int id, string number) =>
new(Guid.NewGuid())
{
Id = id,
Number = number,
SortNumber = id,
Name = $"Channel {number}",
Group = "Test",
Categories = string.Empty,
PreferredAudioLanguageCode = string.Empty
};
}
@@ -6,6 +6,7 @@ using ErsatzTV.Application.Playouts;
using ErsatzTV.Controllers.Api;
using ErsatzTV.Controllers.Api.Requests;
using ErsatzTV.Core;
using ErsatzTV.Core.Api.Channels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using LanguageExt;
@@ -34,6 +35,25 @@ public class ChannelControllerTests
_controller = new ChannelController(writer, _mediator);
}
[Test]
public async Task GetState_Should_Return_200_With_Channel_State()
{
var state = new ChannelStateResponseModel(
7,
"7.1",
true,
new ChannelNowPlayingResponseModel(
"Retro Cartoons",
new DateTimeOffset(2026, 7, 2, 17, 0, 0, TimeSpan.Zero),
new DateTimeOffset(2026, 7, 2, 17, 30, 0, TimeSpan.Zero)));
_mediator.Send(Arg.Any<GetChannelStatesForApi>(), Arg.Any<CancellationToken>())
.Returns([state]);
List<ChannelStateResponseModel> result = await _controller.GetState(CancellationToken.None);
result.ShouldBe(new List<ChannelStateResponseModel> { state });
}
[Test]
public async Task Create_Should_Return_201_With_Location_And_Body()
{
@@ -41,6 +41,64 @@ public class OpenApiErrorResponseContractTests
schema.TryGetProperty("categories", out _).ShouldBeTrue();
}
[Test]
public void Static_OpenApi_Should_Document_Channel_State_Response()
{
using JsonDocument document = JsonDocument.Parse(File.ReadAllText(FindOpenApiDocument()));
JsonElement getState = document.RootElement
.GetProperty("paths")
.GetProperty("/api/channels/state")
.GetProperty("get");
JsonElement schema = getState
.GetProperty("responses")
.GetProperty("200")
.GetProperty("content")
.GetProperty("application/json")
.GetProperty("schema");
schema.GetProperty("type").GetString().ShouldBe("array");
schema.GetProperty("items").GetProperty("$ref").GetString()
.ShouldBe("#/components/schemas/ChannelStateResponseModel");
JsonElement stateProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelStateResponseModel")
.GetProperty("properties");
stateProperties.TryGetProperty("channelId", out _).ShouldBeTrue();
stateProperties.TryGetProperty("channelNumber", out _).ShouldBeTrue();
stateProperties.TryGetProperty("onAir", out _).ShouldBeTrue();
stateProperties.TryGetProperty("nowPlaying", out JsonElement nowPlaying).ShouldBeTrue();
JsonElement nowPlayingOneOf = nowPlaying.GetProperty("oneOf");
nowPlayingOneOf.EnumerateArray().Count().ShouldBe(2);
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("type", out JsonElement type) && type.GetString() == "null")
.ShouldBeTrue();
nowPlayingOneOf.EnumerateArray()
.Any(s => s.TryGetProperty("$ref", out JsonElement schemaRef) &&
schemaRef.GetString() == "#/components/schemas/ChannelNowPlayingResponseModel")
.ShouldBeTrue();
JsonElement nowPlayingProperties = document.RootElement
.GetProperty("components")
.GetProperty("schemas")
.GetProperty("ChannelNowPlayingResponseModel")
.GetProperty("properties");
nowPlayingProperties.TryGetProperty("title", out _).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("startUtc", out JsonElement startUtc).ShouldBeTrue();
nowPlayingProperties.TryGetProperty("finishUtc", out JsonElement finishUtc).ShouldBeTrue();
startUtc.GetProperty("type").GetString().ShouldBe("string");
startUtc.GetProperty("format").GetString().ShouldBe("date-time");
finishUtc.GetProperty("type").GetString().ShouldBe("string");
finishUtc.GetProperty("format").GetString().ShouldBe("date-time");
}
[TestCase("/api/channels/{id}", "get", "404")]
[TestCase("/api/channels", "post", "404")]
[TestCase("/api/channels", "post", "422")]
@@ -21,6 +21,13 @@ public class ChannelController(ChannelWriter<IBackgroundServiceRequest> workerCh
[EndpointGroupName("general")]
public async Task<List<ChannelResponseModel>> GetAll() => await mediator.Send(new GetAllChannelsForApi());
[HttpGet("/api/channels/state")]
[Tags("Channels")]
[EndpointSummary("Get channel runtime state")]
[EndpointGroupName("general")]
public async Task<List<ChannelStateResponseModel>> GetState(CancellationToken cancellationToken) =>
await mediator.Send(new GetChannelStatesForApi(DateTime.UtcNow), cancellationToken);
[HttpGet("/api/channels/{id:int}", Name = "GetChannelById")]
[Tags("Channels")]
[EndpointSummary("Get a channel by id")]
+91
View File
@@ -136,6 +136,45 @@
}
}
},
"/api/channels/state": {
"get": {
"tags": [
"Channels"
],
"summary": "Get channel runtime state",
"responses": {
"200": {
"description": "OK",
"content": {
"text/plain": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
},
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
},
"text/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChannelStateResponseModel"
}
}
}
}
}
}
}
},
"/api/channels/{id}": {
"get": {
"tags": [
@@ -3387,6 +3426,27 @@
],
"type": "string"
},
"ChannelNowPlayingResponseModel": {
"required": [
"title",
"startUtc",
"finishUtc"
],
"type": "object",
"properties": {
"title": {
"type": "string"
},
"startUtc": {
"type": "string",
"format": "date-time"
},
"finishUtc": {
"type": "string",
"format": "date-time"
}
}
},
"ChannelPlayoutMode": {
"enum": [
"Continuous",
@@ -3482,6 +3542,37 @@
],
"type": "string"
},
"ChannelStateResponseModel": {
"required": [
"channelId",
"channelNumber",
"onAir",
"nowPlaying"
],
"type": "object",
"properties": {
"channelId": {
"type": "integer",
"format": "int32"
},
"channelNumber": {
"type": "string"
},
"onAir": {
"type": "boolean"
},
"nowPlaying": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/ChannelNowPlayingResponseModel"
}
]
}
}
},
"ChannelStreamSelectorMode": {
"enum": [
"Default",
@@ -84,6 +84,10 @@ The direct streaming modes can be actively streaming while `onAir` reports false
When no runtime data is available, the response should degrade to `onAir: false` and `nowPlaying: null`. The SPA can render that as Idle / Off air.
`nowPlaying` is also `null` when the current playout item is filler with no program item in its guide group (for example a channel looping fallback filler). When the current item is filler *inside* a program's guide group (pre/mid-roll), the endpoint surfaces the program — matching the XMLTV guide — not the filler.
Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted, like the #99 `onAir` caveat.
## Implementation Shape
Add a new MediatR query in `ErsatzTV.Application.Channels`, for example `GetChannelStatesForApi`, returning `List<ChannelStateResponseModel>`.
+11
View File
@@ -38,6 +38,11 @@ export interface components {
};
"ChannelIdleBehavior": "StopOnDisconnect" | "KeepRunning";
"ChannelMusicVideoCreditsMode": "None" | "GenerateSubtitles";
"ChannelNowPlayingResponseModel": {
"title": string;
"startUtc": string;
"finishUtc": string;
};
"ChannelPlayoutMode": "Continuous" | "OnDemand";
"ChannelPlayoutSource": "Generated" | "Mirror";
"ChannelResponseModel": {
@@ -54,6 +59,12 @@ export interface components {
"showInEpg": boolean;
};
"ChannelSongVideoMode": "Default" | "WithProgress";
"ChannelStateResponseModel": {
"channelId": number;
"channelNumber": string;
"onAir": boolean;
"nowPlaying": null | components["schemas"]["ChannelNowPlayingResponseModel"];
};
"ChannelStreamSelectorMode": "Default" | "Custom" | "Troubleshooting";
"ChannelSubtitleMode": "None" | "Forced" | "Default" | "Any";
"ChannelTranscodeMode": "OnDemand";