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);
}