From 4b4e2b2f820bfe46a7e6d333fafd989a107711cf Mon Sep 17 00:00:00 2001 From: Timothy Date: Fri, 3 Jul 2026 23:24:24 +0200 Subject: [PATCH] feat(api): add schedule item duration estimates refs #111 --- .../ProgramScheduleItemViewModel.cs | 11 +- ...ogramScheduleItemsWithDurationViewModel.cs | 5 +- ...rogramScheduleItemsWithDurationsHandler.cs | 3 +- .../ScheduleItemDurationEstimator.cs | 7 +- ...mScheduleItemsWithDurationsHandlerTests.cs | 247 ++++++++++++++++++ .../Controllers/ScheduleControllerTests.cs | 10 +- ErsatzTV/wwwroot/openapi/v1.json | 48 +++- 7 files changed, 304 insertions(+), 27 deletions(-) create mode 100644 ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index cade2456e..4ebf51d6e 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -46,7 +46,7 @@ public abstract record ProgramScheduleItemViewModel( { /// /// A rough estimate, in wall-clock time, of how long a single pass of this schedule item will play, - /// derived from the aggregated runtimes (MediaVersion.Duration) of the referenced content. + /// derived from the aggregated playout runtimes of the referenced content. /// /// Semantics by : /// @@ -57,15 +57,12 @@ public abstract record ProgramScheduleItemViewModel( /// . /// /// Flood — always null: a flood item fills the remaining time and is unbounded. - /// - /// Duration — always null: its runtime is the explicit - /// playoutDuration setting already present on the item, so it is not re-derived here. - /// + /// Duration — the explicit playoutDuration setting on the item. /// /// /// - /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood/Duration), - /// a referenced collection with no items that have a known non-zero duration, a Multiple mode other + /// null whenever a bounded estimate cannot be produced — an unbounded mode (Flood), + /// a referenced collection with no items that have a known positive duration, a Multiple mode other /// than Count/CollectionSize, or a collection type other than /// (smart/multi/playlist/search/rerun/show/season/artist references are not aggregated in this pass). /// Callers should treat a null as "unknown", never as zero. diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs index 305cbfb1f..0d0fa4623 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs @@ -10,9 +10,8 @@ namespace ErsatzTV.Application.ProgramSchedules; /// /// The sum of every non-null per-item estimate, i.e. a rough runtime for a single pass through the /// estimable items. null when no item in the schedule could be estimated (for example a -/// schedule made up entirely of Flood/Duration items, or of collection types that are not -/// aggregated). Because unbounded items contribute nothing, this is a lower bound, never an exact -/// schedule length. +/// schedule made up entirely of Flood items, or of collection types that are not aggregated). +/// Because unbounded items contribute nothing, this is a lower bound, never an exact schedule length. /// public record ProgramScheduleItemsWithDurationViewModel( List Items, diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs index 7e2563dd2..72c9a1271 100644 --- a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs @@ -54,7 +54,8 @@ public class GetProgramScheduleItemsWithDurationsHandler( List mediaItems = await mediaCollectionRepository.GetItems(collectionId); List durations = mediaItems - .Bind(mediaItem => mediaItem.GetNonZeroDuration()) + .Map(mediaItem => mediaItem.GetDurationForPlayout()) + .Filter(duration => duration > TimeSpan.Zero) .ToList(); if (durations.Count > 0) diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs index 05aeced01..9a6a817a3 100644 --- a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs @@ -29,6 +29,11 @@ internal static class ScheduleItemDurationEstimator ProgramScheduleItemViewModel item, IReadOnlyDictionary durationsByCollectionId) { + if (item is ProgramScheduleItemDurationViewModel durationItem) + { + return durationItem.PlayoutDuration; + } + // only plain collections are aggregated in this pass if (item.CollectionType is not CollectionType.Collection || item.Collection is null) { @@ -48,7 +53,7 @@ internal static class ScheduleItemDurationEstimator // a fixed count of items, or the whole collection once ProgramScheduleItemMultipleViewModel multiple => EstimateMultiple(multiple, duration), - // Flood (unbounded fill) and Duration (bounded by its own playoutDuration setting) are not derived here + // Flood is an unbounded fill; Duration is handled above from its explicit playoutDuration. _ => null }; } diff --git a/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs new file mode 100644 index 000000000..c1c1d525e --- /dev/null +++ b/ErsatzTV.Tests/Application/ProgramSchedules/GetProgramScheduleItemsWithDurationsHandlerTests.cs @@ -0,0 +1,247 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Application.ProgramSchedules; +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Scheduling; +using MediatR; +using NSubstitute; +using NUnit.Framework; +using Shouldly; + +namespace ErsatzTV.Tests.Application.ProgramSchedules; + +[TestFixture] +public class GetProgramScheduleItemsWithDurationsHandlerTests +{ + private IMediaCollectionRepository _mediaCollectionRepository = null!; + private IMediator _mediator = null!; + + [SetUp] + public void SetUp() + { + _mediator = Substitute.For(); + _mediaCollectionRepository = Substitute.For(); + } + + [Test] + public async Task Handle_Should_Estimate_Items_And_Total_From_Collection_Durations() + { + ProgramScheduleItemViewModel one = MakeOneItem(1, 10); + ProgramScheduleItemViewModel multipleCount = MakeMultipleItem(2, 10, MultipleMode.Count, "3"); + ProgramScheduleItemViewModel multipleCollectionSize = MakeMultipleItem(3, 20, MultipleMode.CollectionSize, "0"); + ProgramScheduleItemViewModel duration = MakeDurationItem(4, TimeSpan.FromMinutes(45)); + ProgramScheduleItemViewModel flood = MakeFloodItem(5, 10); + ProgramScheduleItemViewModel remote = MakeOneItem(6, 30); + + _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any()) + .Returns([one, multipleCount, multipleCollectionSize, duration, flood, remote]); + _mediaCollectionRepository.GetItems(10) + .Returns([MakeMovie(30), MakeMovie(60), MakeMovie(0)]); + _mediaCollectionRepository.GetItems(20) + .Returns([MakeMovie(10), MakeMovie(20)]); + _mediaCollectionRepository.GetItems(30) + .Returns([MakeRemoteStreamWithFallbackDuration(20)]); + + var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository); + + ProgramScheduleItemsWithDurationViewModel result = + await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None); + + result.Items[0].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45)); + result.Items[1].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(135)); + result.Items[2].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(30)); + result.Items[3].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(45)); + result.Items[4].DurationEstimate.ShouldBeNull(); + result.Items[5].DurationEstimate.ShouldBe(TimeSpan.FromMinutes(20)); + result.TotalDurationEstimate.ShouldBe(TimeSpan.FromMinutes(275)); + } + + [Test] + public async Task Handle_Should_Return_Null_Total_When_No_Items_Can_Be_Estimated() + { + ProgramScheduleItemViewModel one = MakeOneItem(1, 10); + ProgramScheduleItemViewModel flood = MakeFloodItem(2, 10); + + _mediator.Send(Arg.Is(q => q.Id == 99), Arg.Any()) + .Returns([one, flood]); + _mediaCollectionRepository.GetItems(10) + .Returns([MakeMovie(0)]); + + var handler = new GetProgramScheduleItemsWithDurationsHandler(_mediator, _mediaCollectionRepository); + + ProgramScheduleItemsWithDurationViewModel result = + await handler.Handle(new GetProgramScheduleItemsWithDurations(99), CancellationToken.None); + + result.Items.ShouldAllBe(item => item.DurationEstimate == null); + result.TotalDurationEstimate.ShouldBeNull(); + } + + private static ProgramScheduleItemOneViewModel MakeOneItem(int id, int collectionId) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemMultipleViewModel MakeMultipleItem( + int id, + int collectionId, + MultipleMode multipleMode, + string count) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + multipleMode, + count, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemDurationViewModel MakeDurationItem(int id, TimeSpan playoutDuration) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(10), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + playoutDuration, + TailMode.None, + 0, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static ProgramScheduleItemFloodViewModel MakeFloodItem(int id, int collectionId) => + new( + id, + id, + StartType.Dynamic, + null, + null, + CollectionType.Collection, + MakeCollection(collectionId), + null, + null, + null, + null, + null, + null, + null, + PlaybackOrder.Shuffle, + MarathonGroupBy.None, + false, + false, + null, + FillWithGroupMode.None, + null, + GuideMode.Normal, + null, + null, + null, + null, + null, + [], + [], + null, + null, + null, + null); + + private static MediaCollectionViewModel MakeCollection(int id) => + new(CollectionType.Collection, id, $"Collection {id}", false, MediaItemState.Normal); + + private static Movie MakeMovie(int minutes) => + new() + { + MediaVersions = [new MediaVersion { Duration = TimeSpan.FromMinutes(minutes) }] + }; + + private static RemoteStream MakeRemoteStreamWithFallbackDuration(int minutes) => + new() + { + Duration = TimeSpan.FromMinutes(minutes), + MediaVersions = [new MediaVersion { Duration = TimeSpan.Zero }] + }; +} diff --git a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs index 151674a4d..91a19e90c 100644 --- a/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs +++ b/ErsatzTV.Tests/Controllers/ScheduleControllerTests.cs @@ -189,14 +189,18 @@ public class ScheduleControllerTests public async Task GetItems_Should_Return_200_With_Items() { List items = [MakeOneItem(11)]; + var response = new ProgramScheduleItemsWithDurationViewModel(items, TimeSpan.FromMinutes(25)); _mediator.Send(Arg.Any(), Arg.Any()) .Returns(Option.Some(MakeSchedule(4, "Daily"))); - _mediator.Send(Arg.Any(), Arg.Any()) - .Returns(items); + _mediator.Send(Arg.Any(), Arg.Any()) + .Returns(response); IActionResult result = await _controller.GetItems(4, CancellationToken.None); - result.ShouldBeOfType().Value.ShouldBe(items); + result.ShouldBeOfType().Value.ShouldBe(response); + await _mediator.Received(1).Send( + Arg.Is(q => q.Id == 4), + Arg.Any()); } [Test] diff --git a/ErsatzTV/wwwroot/openapi/v1.json b/ErsatzTV/wwwroot/openapi/v1.json index eb4ab1c7e..76730ed41 100644 --- a/ErsatzTV/wwwroot/openapi/v1.json +++ b/ErsatzTV/wwwroot/openapi/v1.json @@ -2580,6 +2580,7 @@ "Schedules" ], "summary": "Get schedule items", + "description": "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are derived from referenced collection/media runtimes and are null when unbounded or unknown.", "parameters": [ { "name": "id", @@ -2597,26 +2598,17 @@ "content": { "text/plain": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } }, "application/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } }, "text/json": { "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProgramScheduleItemViewModel" - } + "$ref": "#/components/schemas/ProgramScheduleItemsWithDurationViewModel" } } } @@ -5445,6 +5437,31 @@ } } }, + "ProgramScheduleItemsWithDurationViewModel": { + "required": [ + "items", + "totalDurationEstimate" + ], + "type": "object", + "properties": { + "items": { + "type": [ + "null", + "array" + ], + "items": { + "$ref": "#/components/schemas/ProgramScheduleItemViewModel" + } + }, + "totalDurationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + } + } + }, "ProgramScheduleItemViewModel": { "type": "object", "properties": { @@ -5604,6 +5621,13 @@ } ] }, + "durationEstimate": { + "pattern": "^-?(\\d+\\.)?\\d{2}:\\d{2}:\\d{2}(\\.\\d{1,7})?$", + "type": [ + "null", + "string" + ] + }, "name": { "type": [ "null",