From d0652d4adbf74c65efbd121f8e550a8cda6eafd3 Mon Sep 17 00:00:00 2001 From: Timothy Date: Thu, 2 Jul 2026 21:27:41 +0200 Subject: [PATCH] wip: partial implementation salvaged from interrupted workflow run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Untrusted draft — no build/test had run yet. Review before building on it. Co-Authored-By: Claude Fable 5 --- .../ProgramScheduleItemViewModel.cs | 29 ++++++++ ...ogramScheduleItemsWithDurationViewModel.cs | 19 +++++ .../GetProgramScheduleItemsWithDurations.cs | 4 ++ ...rogramScheduleItemsWithDurationsHandler.cs | 69 +++++++++++++++++++ .../ScheduleItemDurationEstimator.cs | 68 ++++++++++++++++++ .../Controllers/Api/ScheduleController.cs | 10 ++- 6 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs create mode 100644 ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs index bbd48faa0..cade2456e 100644 --- a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs @@ -44,6 +44,35 @@ public abstract record ProgramScheduleItemViewModel( string PreferredSubtitleLanguageCode, ChannelSubtitleMode? SubtitleMode) { + /// + /// 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. + /// + /// Semantics by : + /// + /// One — the average runtime of one item in the referenced collection. + /// + /// Multiple — the average item runtime multiplied by the configured count + /// (), or the whole collection runtime for + /// . + /// + /// 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. + /// + /// + /// + /// + /// 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 + /// 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. + /// + /// + public TimeSpan? DurationEstimate { get; init; } + public string Name => CollectionType switch { CollectionType.Collection => Collection?.Name, diff --git a/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs new file mode 100644 index 000000000..305cbfb1f --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemsWithDurationViewModel.cs @@ -0,0 +1,19 @@ +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// The items of a schedule together with computed runtime estimates. Each item carries its own +/// (nullable — see that property for +/// the per-mode semantics), and is the sum of the items that +/// could be estimated. +/// +/// The schedule items, each with a nullable DurationEstimate. +/// +/// 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. +/// +public record ProgramScheduleItemsWithDurationViewModel( + List Items, + TimeSpan? TotalDurationEstimate); diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs new file mode 100644 index 000000000..abfa27406 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurations.cs @@ -0,0 +1,4 @@ +namespace ErsatzTV.Application.ProgramSchedules; + +public record GetProgramScheduleItemsWithDurations(int Id) + : IRequest; diff --git a/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs new file mode 100644 index 000000000..7e2563dd2 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Queries/GetProgramScheduleItemsWithDurationsHandler.cs @@ -0,0 +1,69 @@ +using ErsatzTV.Core.Domain; +using ErsatzTV.Core.Extensions; +using ErsatzTV.Core.Interfaces.Repositories; +using static ErsatzTV.Application.ProgramSchedules.ScheduleItemDurationEstimator; + +namespace ErsatzTV.Application.ProgramSchedules; + +public class GetProgramScheduleItemsWithDurationsHandler( + IMediator mediator, + IMediaCollectionRepository mediaCollectionRepository) + : IRequestHandler +{ + public async Task Handle( + GetProgramScheduleItemsWithDurations request, + CancellationToken cancellationToken) + { + List items = + await mediator.Send(new GetProgramScheduleItems(request.Id), cancellationToken); + + Dictionary durationsByCollectionId = + await AggregateReferencedCollections(items); + + var itemsWithEstimates = items + .Map(item => item with { DurationEstimate = Estimate(item, durationsByCollectionId) }) + .ToList(); + + List estimates = itemsWithEstimates + .Map(item => Optional(item.DurationEstimate)) + .Somes() + .ToList(); + + TimeSpan? total = estimates.Count > 0 + ? TimeSpan.FromTicks(estimates.Sum(estimate => estimate.Ticks)) + : null; + + return new ProgramScheduleItemsWithDurationViewModel(itemsWithEstimates, total); + } + + // Aggregate MediaVersion.Duration once per distinct referenced collection (not per item). Only plain + // collections are resolved here; other reference types are estimated as null (see DurationEstimate docs). + private async Task> AggregateReferencedCollections( + IReadOnlyList items) + { + List collectionIds = items + .Filter(item => item.CollectionType is CollectionType.Collection && item.Collection is not null) + .Filter(item => item.PlayoutMode is PlayoutMode.One or PlayoutMode.Multiple) + .Map(item => item.Collection.Id) + .Distinct() + .ToList(); + + var result = new Dictionary(); + foreach (int collectionId in collectionIds) + { + List mediaItems = await mediaCollectionRepository.GetItems(collectionId); + + List durations = mediaItems + .Bind(mediaItem => mediaItem.GetNonZeroDuration()) + .ToList(); + + if (durations.Count > 0) + { + var total = TimeSpan.FromTicks(durations.Sum(duration => duration.Ticks)); + result[collectionId] = new CollectionDuration(total, durations.Count); + } + } + + return result; + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs new file mode 100644 index 000000000..05aeced01 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/ScheduleItemDurationEstimator.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using ErsatzTV.Core.Domain; + +namespace ErsatzTV.Application.ProgramSchedules; + +/// +/// Pure computation of per-item runtime estimates from pre-aggregated collection durations. +/// Kept separate from the query handler so the (non-trivial) per-mode math can be unit-tested +/// without a database. See for the +/// documented semantics this implements. +/// +internal static class ScheduleItemDurationEstimator +{ + /// + /// Aggregate runtime of a single referenced collection: the total runtime of every item with a + /// known non-zero duration, and how many such items there are. + /// + public sealed record CollectionDuration(TimeSpan Total, int ItemCount) + { + public TimeSpan? Average => ItemCount > 0 ? Total / ItemCount : null; + } + + /// + /// Estimate the runtime of one pass of , or null when no bounded + /// estimate is possible. holds aggregates only for the + /// plain collections that were resolved; a missing entry yields null. + /// + public static TimeSpan? Estimate( + ProgramScheduleItemViewModel item, + IReadOnlyDictionary durationsByCollectionId) + { + // only plain collections are aggregated in this pass + if (item.CollectionType is not CollectionType.Collection || item.Collection is null) + { + return null; + } + + if (!durationsByCollectionId.TryGetValue(item.Collection.Id, out CollectionDuration duration)) + { + return null; + } + + return item switch + { + // one item per pass -> the average item runtime + ProgramScheduleItemOneViewModel => duration.Average, + + // 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 + _ => null + }; + } + + private static TimeSpan? EstimateMultiple( + ProgramScheduleItemMultipleViewModel multiple, + CollectionDuration duration) => + multiple.MultipleMode switch + { + MultipleMode.Count when + int.TryParse(multiple.Count, NumberStyles.Integer, CultureInfo.InvariantCulture, out int count) + && count > 0 + && duration.Average is { } average => average * count, + MultipleMode.CollectionSize => duration.Total, + _ => null + }; +} diff --git a/ErsatzTV/Controllers/Api/ScheduleController.cs b/ErsatzTV/Controllers/Api/ScheduleController.cs index 7592d3562..2a8d6f5ac 100644 --- a/ErsatzTV/Controllers/Api/ScheduleController.cs +++ b/ErsatzTV/Controllers/Api/ScheduleController.cs @@ -99,8 +99,12 @@ public class ScheduleController(IMediator mediator) : ControllerBase [HttpGet("/api/schedules/{id:int}/items")] [Tags("Schedules")] [EndpointSummary("Get schedule items")] + [EndpointDescription( + "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.")] [EndpointGroupName("general")] - [ProducesResponseType(typeof(List), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProgramScheduleItemsWithDurationViewModel), StatusCodes.Status200OK)] [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] public async Task GetItems(int id, CancellationToken cancellationToken) { @@ -110,8 +114,8 @@ public class ScheduleController(IMediator mediator) : ControllerBase return ApiResults.NotFoundProblem(); } - List items = - await mediator.Send(new GetProgramScheduleItems(id), cancellationToken); + ProgramScheduleItemsWithDurationViewModel items = + await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken); return new OkObjectResult(items); }