feat(392): per-schedule clock-boundary padding toggle + 60-min increment #573
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -9,4 +9,5 @@ public record CreateProgramSchedule(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -40,7 +40,8 @@ public class CreateProgramScheduleHandler(IDbContextFactory<TvContext> dbContext
|
||||
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems = request.ShuffleScheduleItems,
|
||||
RandomStartPoint = request.RandomStartPoint,
|
||||
FixedStartTimeBehavior = request.FixedStartTimeBehavior
|
||||
FixedStartTimeBehavior = request.FixedStartTimeBehavior,
|
||||
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -10,4 +10,5 @@ public record UpdateProgramSchedule(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
|
||||
@@ -40,12 +40,15 @@ public class UpdateProgramScheduleHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// we need to refresh playouts if the playback order or keep multi-episodes has been modified
|
||||
int? normalizedPad = request.PadToNearestMinute is int upm && upm > 0 ? upm : null;
|
||||
|
||||
bool needToRefreshPlayout =
|
||||
programSchedule.KeepMultiPartEpisodesTogether != request.KeepMultiPartEpisodesTogether ||
|
||||
programSchedule.TreatCollectionsAsShows != request.TreatCollectionsAsShows ||
|
||||
programSchedule.ShuffleScheduleItems != request.ShuffleScheduleItems ||
|
||||
programSchedule.RandomStartPoint != request.RandomStartPoint ||
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior;
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
|
||||
programSchedule.PadToNearestMinute != normalizedPad;
|
||||
|
||||
programSchedule.Name = request.Name;
|
||||
programSchedule.KeepMultiPartEpisodesTogether = request.KeepMultiPartEpisodesTogether;
|
||||
@@ -54,6 +57,7 @@ public class UpdateProgramScheduleHandler(
|
||||
programSchedule.ShuffleScheduleItems = request.ShuffleScheduleItems;
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
|
||||
programSchedule.PadToNearestMinute = normalizedPad;
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253).
|
||||
// Force-write past a concurrent Version bump (e.g. a parallel schedule-items replace) instead of
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -13,6 +13,7 @@ internal static class Mapper
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior,
|
||||
programSchedule.PadToNearestMinute,
|
||||
programSchedule.Version);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
|
||||
@@ -10,4 +10,5 @@ public record ProgramScheduleViewModel(
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute,
|
||||
int Version);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Application.ProgramSchedules;
|
||||
@@ -20,6 +20,7 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint,
|
||||
ps.FixedStartTimeBehavior,
|
||||
ps.PadToNearestMinute,
|
||||
ps.Version))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
001 | 2026-01-15 00:22:00 - 2026-01-15 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
002 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
003 | 2026-01-15 01:07:00 - 2026-01-15 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
004 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
005 | 2026-01-15 02:07:00 - 2026-01-15 02:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
006 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
007 | 2026-01-15 02:37:00 - 2026-01-15 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
008 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
009 | 2026-01-15 03:22:00 - 2026-01-15 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
010 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
011 | 2026-01-15 04:22:00 - 2026-01-15 04:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
012 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
013 | 2026-01-15 04:52:00 - 2026-01-15 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
014 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
015 | 2026-01-15 05:37:00 - 2026-01-15 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
016 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
017 | 2026-01-15 06:37:00 - 2026-01-15 06:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
018 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
019 | 2026-01-15 07:07:00 - 2026-01-15 07:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
020 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
021 | 2026-01-15 07:52:00 - 2026-01-15 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
022 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
023 | 2026-01-15 08:52:00 - 2026-01-15 09:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
024 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
025 | 2026-01-15 09:22:00 - 2026-01-15 09:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
026 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
027 | 2026-01-15 10:07:00 - 2026-01-15 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
028 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
029 | 2026-01-15 11:07:00 - 2026-01-15 11:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
030 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
031 | 2026-01-15 11:37:00 - 2026-01-15 11:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
032 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
033 | 2026-01-15 12:22:00 - 2026-01-15 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
034 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
035 | 2026-01-15 13:22:00 - 2026-01-15 13:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
036 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
037 | 2026-01-15 13:52:00 - 2026-01-15 14:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
038 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
039 | 2026-01-15 14:37:00 - 2026-01-15 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
040 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
041 | 2026-01-15 15:37:00 - 2026-01-15 15:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
042 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
043 | 2026-01-15 16:07:00 - 2026-01-15 16:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
044 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
045 | 2026-01-15 16:52:00 - 2026-01-15 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
046 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
047 | 2026-01-15 17:52:00 - 2026-01-15 18:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
048 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
049 | 2026-01-15 18:22:00 - 2026-01-15 18:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
050 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
051 | 2026-01-15 19:07:00 - 2026-01-15 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
052 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
053 | 2026-01-15 20:07:00 - 2026-01-15 20:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
054 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
055 | 2026-01-15 20:37:00 - 2026-01-15 20:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
056 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
057 | 2026-01-15 21:22:00 - 2026-01-15 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
058 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
059 | 2026-01-15 22:22:00 - 2026-01-15 22:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
060 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
061 | 2026-01-15 22:52:00 - 2026-01-15 23:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
062 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
063 | 2026-01-15 23:37:00 - 2026-01-15 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
064 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
065 | 2026-01-16 00:37:00 - 2026-01-16 00:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
066 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
067 | 2026-01-16 01:07:00 - 2026-01-16 01:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
068 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
069 | 2026-01-16 01:52:00 - 2026-01-16 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
070 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
071 | 2026-01-16 02:52:00 - 2026-01-16 03:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
072 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
073 | 2026-01-16 03:22:00 - 2026-01-16 03:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
074 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
075 | 2026-01-16 04:07:00 - 2026-01-16 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
076 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
077 | 2026-01-16 05:07:00 - 2026-01-16 05:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
078 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
079 | 2026-01-16 05:37:00 - 2026-01-16 05:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
080 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
081 | 2026-01-16 06:22:00 - 2026-01-16 06:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
082 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
083 | 2026-01-16 07:22:00 - 2026-01-16 07:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
084 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
085 | 2026-01-16 07:52:00 - 2026-01-16 08:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
086 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
087 | 2026-01-16 08:37:00 - 2026-01-16 08:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
088 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
089 | 2026-01-16 09:37:00 - 2026-01-16 09:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
090 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
091 | 2026-01-16 10:07:00 - 2026-01-16 10:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
092 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
093 | 2026-01-16 10:52:00 - 2026-01-16 11:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
094 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
095 | 2026-01-16 11:52:00 - 2026-01-16 12:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
096 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
097 | 2026-01-16 12:22:00 - 2026-01-16 12:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
098 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
099 | 2026-01-16 13:07:00 - 2026-01-16 13:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
100 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
101 | 2026-01-16 14:07:00 - 2026-01-16 14:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
102 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
103 | 2026-01-16 14:37:00 - 2026-01-16 14:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
104 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
105 | 2026-01-16 15:22:00 - 2026-01-16 15:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
106 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
107 | 2026-01-16 16:22:00 - 2026-01-16 16:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
108 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
109 | 2026-01-16 16:52:00 - 2026-01-16 17:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
110 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
111 | 2026-01-16 17:37:00 - 2026-01-16 17:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
112 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
113 | 2026-01-16 18:37:00 - 2026-01-16 18:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
114 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
115 | 2026-01-16 19:07:00 - 2026-01-16 19:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
116 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
117 | 2026-01-16 19:52:00 - 2026-01-16 20:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
118 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
119 | 2026-01-16 20:52:00 - 2026-01-16 21:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
120 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Fallback 01
|
||||
121 | 2026-01-16 21:22:00 - 2026-01-16 21:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
122 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Fallback 02
|
||||
123 | 2026-01-16 22:07:00 - 2026-01-16 22:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
124 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Fallback 03
|
||||
125 | 2026-01-16 23:07:00 - 2026-01-16 23:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
126 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Fallback 01
|
||||
127 | 2026-01-16 23:37:00 - 2026-01-16 23:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
128 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Fallback 02
|
||||
129 | 2026-01-17 00:22:00 - 2026-01-17 00:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
130 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Fallback 03
|
||||
131 | 2026-01-17 01:22:00 - 2026-01-17 01:30:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
132 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Fallback 01
|
||||
133 | 2026-01-17 01:52:00 - 2026-01-17 02:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
134 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Fallback 02
|
||||
135 | 2026-01-17 02:37:00 - 2026-01-17 02:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
136 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Fallback 03
|
||||
137 | 2026-01-17 03:37:00 - 2026-01-17 03:45:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
138 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Fallback 01
|
||||
139 | 2026-01-17 04:07:00 - 2026-01-17 04:15:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
140 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Fallback 02
|
||||
141 | 2026-01-17 04:52:00 - 2026-01-17 05:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
142 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Fallback 03
|
||||
143 | 2026-01-17 05:52:00 - 2026-01-17 06:00:00 | Fallback | Schedule Fallback Filler Clip Fallback
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:22:00 | None | Schedule Padded Movie Offline 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:07:00 | None | Schedule Padded Movie Offline 02
|
||||
002 | 2026-01-15 01:15:00 - 2026-01-15 02:07:00 | None | Schedule Padded Movie Offline 03
|
||||
003 | 2026-01-15 02:15:00 - 2026-01-15 02:37:00 | None | Schedule Padded Movie Offline 01
|
||||
004 | 2026-01-15 02:45:00 - 2026-01-15 03:22:00 | None | Schedule Padded Movie Offline 02
|
||||
005 | 2026-01-15 03:30:00 - 2026-01-15 04:22:00 | None | Schedule Padded Movie Offline 03
|
||||
006 | 2026-01-15 04:30:00 - 2026-01-15 04:52:00 | None | Schedule Padded Movie Offline 01
|
||||
007 | 2026-01-15 05:00:00 - 2026-01-15 05:37:00 | None | Schedule Padded Movie Offline 02
|
||||
008 | 2026-01-15 05:45:00 - 2026-01-15 06:37:00 | None | Schedule Padded Movie Offline 03
|
||||
009 | 2026-01-15 06:45:00 - 2026-01-15 07:07:00 | None | Schedule Padded Movie Offline 01
|
||||
010 | 2026-01-15 07:15:00 - 2026-01-15 07:52:00 | None | Schedule Padded Movie Offline 02
|
||||
011 | 2026-01-15 08:00:00 - 2026-01-15 08:52:00 | None | Schedule Padded Movie Offline 03
|
||||
012 | 2026-01-15 09:00:00 - 2026-01-15 09:22:00 | None | Schedule Padded Movie Offline 01
|
||||
013 | 2026-01-15 09:30:00 - 2026-01-15 10:07:00 | None | Schedule Padded Movie Offline 02
|
||||
014 | 2026-01-15 10:15:00 - 2026-01-15 11:07:00 | None | Schedule Padded Movie Offline 03
|
||||
015 | 2026-01-15 11:15:00 - 2026-01-15 11:37:00 | None | Schedule Padded Movie Offline 01
|
||||
016 | 2026-01-15 11:45:00 - 2026-01-15 12:22:00 | None | Schedule Padded Movie Offline 02
|
||||
017 | 2026-01-15 12:30:00 - 2026-01-15 13:22:00 | None | Schedule Padded Movie Offline 03
|
||||
018 | 2026-01-15 13:30:00 - 2026-01-15 13:52:00 | None | Schedule Padded Movie Offline 01
|
||||
019 | 2026-01-15 14:00:00 - 2026-01-15 14:37:00 | None | Schedule Padded Movie Offline 02
|
||||
020 | 2026-01-15 14:45:00 - 2026-01-15 15:37:00 | None | Schedule Padded Movie Offline 03
|
||||
021 | 2026-01-15 15:45:00 - 2026-01-15 16:07:00 | None | Schedule Padded Movie Offline 01
|
||||
022 | 2026-01-15 16:15:00 - 2026-01-15 16:52:00 | None | Schedule Padded Movie Offline 02
|
||||
023 | 2026-01-15 17:00:00 - 2026-01-15 17:52:00 | None | Schedule Padded Movie Offline 03
|
||||
024 | 2026-01-15 18:00:00 - 2026-01-15 18:22:00 | None | Schedule Padded Movie Offline 01
|
||||
025 | 2026-01-15 18:30:00 - 2026-01-15 19:07:00 | None | Schedule Padded Movie Offline 02
|
||||
026 | 2026-01-15 19:15:00 - 2026-01-15 20:07:00 | None | Schedule Padded Movie Offline 03
|
||||
027 | 2026-01-15 20:15:00 - 2026-01-15 20:37:00 | None | Schedule Padded Movie Offline 01
|
||||
028 | 2026-01-15 20:45:00 - 2026-01-15 21:22:00 | None | Schedule Padded Movie Offline 02
|
||||
029 | 2026-01-15 21:30:00 - 2026-01-15 22:22:00 | None | Schedule Padded Movie Offline 03
|
||||
030 | 2026-01-15 22:30:00 - 2026-01-15 22:52:00 | None | Schedule Padded Movie Offline 01
|
||||
031 | 2026-01-15 23:00:00 - 2026-01-15 23:37:00 | None | Schedule Padded Movie Offline 02
|
||||
032 | 2026-01-15 23:45:00 - 2026-01-16 00:37:00 | None | Schedule Padded Movie Offline 03
|
||||
033 | 2026-01-16 00:45:00 - 2026-01-16 01:07:00 | None | Schedule Padded Movie Offline 01
|
||||
034 | 2026-01-16 01:15:00 - 2026-01-16 01:52:00 | None | Schedule Padded Movie Offline 02
|
||||
035 | 2026-01-16 02:00:00 - 2026-01-16 02:52:00 | None | Schedule Padded Movie Offline 03
|
||||
036 | 2026-01-16 03:00:00 - 2026-01-16 03:22:00 | None | Schedule Padded Movie Offline 01
|
||||
037 | 2026-01-16 03:30:00 - 2026-01-16 04:07:00 | None | Schedule Padded Movie Offline 02
|
||||
038 | 2026-01-16 04:15:00 - 2026-01-16 05:07:00 | None | Schedule Padded Movie Offline 03
|
||||
039 | 2026-01-16 05:15:00 - 2026-01-16 05:37:00 | None | Schedule Padded Movie Offline 01
|
||||
040 | 2026-01-16 05:45:00 - 2026-01-16 06:22:00 | None | Schedule Padded Movie Offline 02
|
||||
041 | 2026-01-16 06:30:00 - 2026-01-16 07:22:00 | None | Schedule Padded Movie Offline 03
|
||||
042 | 2026-01-16 07:30:00 - 2026-01-16 07:52:00 | None | Schedule Padded Movie Offline 01
|
||||
043 | 2026-01-16 08:00:00 - 2026-01-16 08:37:00 | None | Schedule Padded Movie Offline 02
|
||||
044 | 2026-01-16 08:45:00 - 2026-01-16 09:37:00 | None | Schedule Padded Movie Offline 03
|
||||
045 | 2026-01-16 09:45:00 - 2026-01-16 10:07:00 | None | Schedule Padded Movie Offline 01
|
||||
046 | 2026-01-16 10:15:00 - 2026-01-16 10:52:00 | None | Schedule Padded Movie Offline 02
|
||||
047 | 2026-01-16 11:00:00 - 2026-01-16 11:52:00 | None | Schedule Padded Movie Offline 03
|
||||
048 | 2026-01-16 12:00:00 - 2026-01-16 12:22:00 | None | Schedule Padded Movie Offline 01
|
||||
049 | 2026-01-16 12:30:00 - 2026-01-16 13:07:00 | None | Schedule Padded Movie Offline 02
|
||||
050 | 2026-01-16 13:15:00 - 2026-01-16 14:07:00 | None | Schedule Padded Movie Offline 03
|
||||
051 | 2026-01-16 14:15:00 - 2026-01-16 14:37:00 | None | Schedule Padded Movie Offline 01
|
||||
052 | 2026-01-16 14:45:00 - 2026-01-16 15:22:00 | None | Schedule Padded Movie Offline 02
|
||||
053 | 2026-01-16 15:30:00 - 2026-01-16 16:22:00 | None | Schedule Padded Movie Offline 03
|
||||
054 | 2026-01-16 16:30:00 - 2026-01-16 16:52:00 | None | Schedule Padded Movie Offline 01
|
||||
055 | 2026-01-16 17:00:00 - 2026-01-16 17:37:00 | None | Schedule Padded Movie Offline 02
|
||||
056 | 2026-01-16 17:45:00 - 2026-01-16 18:37:00 | None | Schedule Padded Movie Offline 03
|
||||
057 | 2026-01-16 18:45:00 - 2026-01-16 19:07:00 | None | Schedule Padded Movie Offline 01
|
||||
058 | 2026-01-16 19:15:00 - 2026-01-16 19:52:00 | None | Schedule Padded Movie Offline 02
|
||||
059 | 2026-01-16 20:00:00 - 2026-01-16 20:52:00 | None | Schedule Padded Movie Offline 03
|
||||
060 | 2026-01-16 21:00:00 - 2026-01-16 21:22:00 | None | Schedule Padded Movie Offline 01
|
||||
061 | 2026-01-16 21:30:00 - 2026-01-16 22:07:00 | None | Schedule Padded Movie Offline 02
|
||||
062 | 2026-01-16 22:15:00 - 2026-01-16 23:07:00 | None | Schedule Padded Movie Offline 03
|
||||
063 | 2026-01-16 23:15:00 - 2026-01-16 23:37:00 | None | Schedule Padded Movie Offline 01
|
||||
064 | 2026-01-16 23:45:00 - 2026-01-17 00:22:00 | None | Schedule Padded Movie Offline 02
|
||||
065 | 2026-01-17 00:30:00 - 2026-01-17 01:22:00 | None | Schedule Padded Movie Offline 03
|
||||
066 | 2026-01-17 01:30:00 - 2026-01-17 01:52:00 | None | Schedule Padded Movie Offline 01
|
||||
067 | 2026-01-17 02:00:00 - 2026-01-17 02:37:00 | None | Schedule Padded Movie Offline 02
|
||||
068 | 2026-01-17 02:45:00 - 2026-01-17 03:37:00 | None | Schedule Padded Movie Offline 03
|
||||
069 | 2026-01-17 03:45:00 - 2026-01-17 04:07:00 | None | Schedule Padded Movie Offline 01
|
||||
070 | 2026-01-17 04:15:00 - 2026-01-17 04:52:00 | None | Schedule Padded Movie Offline 02
|
||||
071 | 2026-01-17 05:00:00 - 2026-01-17 05:52:00 | None | Schedule Padded Movie Offline 03
|
||||
@@ -119,6 +119,138 @@ public class PlayoutBuildGoldenTests
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles);
|
||||
}
|
||||
|
||||
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
|
||||
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
|
||||
// the build clock to the boundary even when nothing fills the gap.
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_offline()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
content.Count.ShouldBeGreaterThan(2);
|
||||
|
||||
// No filler of any kind is emitted (offline gaps only).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
|
||||
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
|
||||
}
|
||||
|
||||
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
|
||||
// the :15 boundary (no offline gap).
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_fallback()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
|
||||
|
||||
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
|
||||
}
|
||||
|
||||
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
|
||||
[Test]
|
||||
public async Task Classic_item_pad_wins_over_schedule_pad()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
|
||||
}
|
||||
|
||||
// #392: the schedule-level pad + OFFLINE advance is shared machinery — AddFiller records the offline
|
||||
// target and every scheduler honors it. The offline goldens only exercise PlayoutModeSchedulerOne, so
|
||||
// these invariant tests cover Flood / Duration / Multiple across a 2-day window (two midnight crossings)
|
||||
// to prove the offline advance AND its day-seam anchor survival are not One-specific. No golden files:
|
||||
// the invariants (boundary alignment of every content item, zero filler emitted, and resumption on a
|
||||
// boundary on both later days) fully pin the behavior and are the exact thing the day-seam clamp fix
|
||||
// must preserve. If the day-boundary anchor clamp wrongly discarded an offline advance, the first
|
||||
// content item after a midnight would land mid-interval and fail here.
|
||||
[TestCase("Flood")]
|
||||
[TestCase("Duration")]
|
||||
[TestCase("Multiple")]
|
||||
public async Task Schedule_clock_padded_offline_multimode(string mode)
|
||||
{
|
||||
List<PlayoutItem> items = await BuildSchedulePaddedModePlayout(mode);
|
||||
|
||||
List<PlayoutItem> content = items
|
||||
.Where(i => i.FillerKind == FillerKind.None)
|
||||
.OrderBy(i => i.Start)
|
||||
.ToList();
|
||||
|
||||
// Sanity: a 2-day window over sub-hour content must yield many items spanning >1 day.
|
||||
content.Count.ShouldBeGreaterThan(10);
|
||||
content.Select(i => i.Start.Date).Distinct().Count().ShouldBeGreaterThan(2);
|
||||
|
||||
// Offline variant: NO filler of any kind is emitted (gaps up to the boundary are left offline).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None, $"[{mode}] offline pad must emit no filler");
|
||||
|
||||
// Every content item begins on a :15 boundary. The very first item is the raw anchor at the pinned
|
||||
// Start (06:00, itself a :15 boundary); every later item — including the first of day 2 and day 3 —
|
||||
// is on a boundary only because the preceding item's offline pad advanced the clock to it AND the
|
||||
// day-boundary anchor clamp preserved that advance across each midnight seam.
|
||||
foreach (PlayoutItem item in content)
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0, $"[{mode}] content item at {item.Start:yyyy-MM-dd HH:mm:ss} is not second-aligned");
|
||||
}
|
||||
|
||||
// Explicit day-seam assertion: the first content item on each day after the first still lands on a
|
||||
// boundary (this is precisely what regressed before the clamp fix, once per simulated midnight).
|
||||
List<PlayoutItem> firstOfEachDay = content
|
||||
.GroupBy(i => i.Start.Date)
|
||||
.OrderBy(g => g.Key)
|
||||
.Select(g => g.OrderBy(i => i.Start).First())
|
||||
.ToList();
|
||||
foreach (PlayoutItem dayStart in firstOfEachDay.Skip(1))
|
||||
{
|
||||
(dayStart.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"[{mode}] first content item of {dayStart.Start:yyyy-MM-dd} at {dayStart.Start:HH:mm:ss} resumed mid-interval");
|
||||
}
|
||||
}
|
||||
|
||||
// Regression for the whole-branch-review defect: Fill-With-Group schedule items (FillWithGroupMode
|
||||
// .FillWithOrderedGroups / FillWithShuffledGroups) are scheduled via a FAKE ProgramScheduleItem that
|
||||
// PlayoutBuilder synthesizes with DeepCopy() (Newtonsoft serialization). ProgramScheduleItem
|
||||
// .ProgramSchedule is [JsonIgnore]'d there, so without reassigning it on the copy, the schedule-level
|
||||
// PadToNearestMinute silently no-ops for fill-with-group items only — the normal (non-group) path
|
||||
// reads the schedule nav that Build() populates centrally and was never broken. No golden: this is an
|
||||
// invariant-only regression test (boundary alignment + zero filler), the same assertions
|
||||
// Classic_schedule_clock_padded_offline uses for the non-group path.
|
||||
[Test]
|
||||
public async Task Schedule_clock_padded_fill_with_group_offline()
|
||||
{
|
||||
List<PlayoutItem> items = await BuildSchedulePaddedFillWithGroupPlayout();
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
content.Count.ShouldBeGreaterThan(2);
|
||||
|
||||
// No filler of any kind is emitted (offline gaps only).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
|
||||
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(
|
||||
0,
|
||||
$"fill-with-group content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Classic + PlaybackOrder.Shuffle: exercises PlayoutBuilder's call into the shuffle-source helper
|
||||
// (GetGroupedMediaItemsForShuffle) that #380 moves to ShuffleSourceBuilder, plus the wiring into
|
||||
// ShuffledMediaCollectionEnumerator. Unlike the chronological fixture, shuffle output depends on the
|
||||
@@ -175,8 +307,9 @@ public class PlayoutBuildGoldenTests
|
||||
{
|
||||
foreach (Func<Task> regen in new Func<Task>[]
|
||||
{
|
||||
Classic_chronological, Block_playout, Classic_clock_padded, Classic_shuffle,
|
||||
Classic_weighted, Sequential_yaml
|
||||
Classic_chronological, Block_playout, Classic_clock_padded,
|
||||
Classic_schedule_clock_padded_offline, Classic_schedule_clock_padded_fallback,
|
||||
Classic_shuffle, Classic_weighted, Sequential_yaml
|
||||
})
|
||||
{
|
||||
try
|
||||
@@ -724,11 +857,12 @@ public class PlayoutBuildGoldenTests
|
||||
|
||||
// --- Clock-boundary pad builder (issue #77) ---
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout()
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildPaddedPlayout(
|
||||
int? schedulePadMinutes = null)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedPaddedData(cancellationToken);
|
||||
var (playoutId, titles) = await SeedPaddedData(cancellationToken, schedulePadMinutes);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
@@ -768,11 +902,17 @@ public class PlayoutBuildGoldenTests
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedPaddedData(
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
int? schedulePadMinutes = null)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Padded LibraryPath" };
|
||||
// Suffix distinguishes this call from the base (schedulePadMinutes: null) call so unique-name/guid
|
||||
// constraints don't collide when both are seeded into the same shared in-memory database. It never
|
||||
// touches a Movie/MovieMetadata title, so the golden snapshot (which only records those) is unaffected.
|
||||
string suffix = schedulePadMinutes.HasValue ? $" SchedulePad{schedulePadMinutes}" : string.Empty;
|
||||
|
||||
var path = new LibraryPath { Path = $"Padded LibraryPath{suffix}" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
@@ -821,12 +961,12 @@ public class PlayoutBuildGoldenTests
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = "Padded Content Collection",
|
||||
Name = $"Padded Content Collection{suffix}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var fillerCollection = new Collection
|
||||
{
|
||||
Name = "Padded Filler Collection",
|
||||
Name = $"Padded Filler Collection{suffix}",
|
||||
MediaItems = new List<MediaItem> { fillerClip }
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
@@ -841,7 +981,7 @@ public class PlayoutBuildGoldenTests
|
||||
// increment (e.g. 10) — it would become machine-TZ dependent and need the Block-style Assume guard.
|
||||
var padFiller = new FillerPreset
|
||||
{
|
||||
Name = "Pad To Quarter Hour",
|
||||
Name = $"Pad To Quarter Hour{suffix}",
|
||||
FillerKind = FillerKind.PostRoll,
|
||||
FillerMode = FillerMode.Pad,
|
||||
PadToNearestMinute = 15,
|
||||
@@ -865,21 +1005,29 @@ public class PlayoutBuildGoldenTests
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Padded FFmpeg Profile" };
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Padded FFmpeg Profile{suffix}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000003"))
|
||||
var channel = new Channel(
|
||||
schedulePadMinutes.HasValue
|
||||
? Guid.Parse("00000000-0000-0000-0000-000000000007")
|
||||
: Guid.Parse("00000000-0000-0000-0000-000000000003"))
|
||||
{
|
||||
Name = "Padded Test Channel",
|
||||
Number = "3",
|
||||
Name = $"Padded Test Channel{suffix}",
|
||||
Number = schedulePadMinutes.HasValue ? "7" : "3",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Padded Test Schedule", Items = scheduleItems };
|
||||
var schedule = new ProgramSchedule { Name = $"Padded Test Schedule{suffix}", Items = scheduleItems };
|
||||
if (schedulePadMinutes.HasValue)
|
||||
{
|
||||
schedule.PadToNearestMinute = schedulePadMinutes.Value;
|
||||
}
|
||||
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
@@ -926,6 +1074,529 @@ public class PlayoutBuildGoldenTests
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// #392: schedule-level PadToNearestMinute (no item-level Pad filler). Mirrors BuildPaddedPlayout/
|
||||
// SeedPaddedData/GetPaddedReferenceData above, but the item has no PostRollFiller and the schedule
|
||||
// itself carries the pad divisor.
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildSchedulePaddedPlayout(
|
||||
bool withFallback)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedSchedulePaddedData(cancellationToken, withFallback);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedSchedulePaddedData(
|
||||
CancellationToken cancellationToken,
|
||||
bool withFallback)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// Suffix distinguishes the offline/fallback variants so unique-name/guid constraints don't
|
||||
// collide when both fixtures are seeded into the same shared in-memory database.
|
||||
string suffix = withFallback ? "Fallback" : "Offline";
|
||||
|
||||
var path = new LibraryPath { Path = $"Schedule Padded LibraryPath {suffix}" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Content: three movies with OFF-boundary durations (22/37/52 min) so padding to :15 is visible.
|
||||
int[] durationsMinutes = [22, 37, 52];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
movies.Add(new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Schedule Padded Movie {suffix} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
// Filler: a SINGLE 1-minute clip, only used for the fallback variant.
|
||||
var fillerClip = new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(1) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Schedule Fallback Filler Clip {suffix}", ReleaseDate = new DateTime(2019, 1, 1) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
};
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.Movies.AddAsync(fillerClip, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var titles = movies.ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
titles[fillerClip.Id] = fillerClip.MovieMetadata[0].Title;
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = $"Schedule Padded Content Collection {suffix}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var fillerCollection = new Collection
|
||||
{
|
||||
Name = $"Schedule Padded Fallback Collection {suffix}",
|
||||
MediaItems = new List<MediaItem> { fillerClip }
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.Collections.AddAsync(fillerCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
FillerPreset fallbackFiller = null;
|
||||
if (withFallback)
|
||||
{
|
||||
fallbackFiller = new FillerPreset
|
||||
{
|
||||
Name = $"Schedule Pad Fallback {suffix}",
|
||||
FillerKind = FillerKind.Fallback,
|
||||
FillerMode = FillerMode.None,
|
||||
CollectionType = CollectionType.Collection,
|
||||
Collection = fillerCollection,
|
||||
CollectionId = fillerCollection.Id
|
||||
};
|
||||
await context.FillerPresets.AddAsync(fallbackFiller, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemOne
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
};
|
||||
if (withFallback)
|
||||
{
|
||||
scheduleItem.FallbackFiller = fallbackFiller;
|
||||
scheduleItem.FallbackFillerId = fallbackFiller.Id;
|
||||
}
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded FFmpeg Profile {suffix}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse(withFallback ? "00000000-0000-0000-0000-000000000009" : "00000000-0000-0000-0000-000000000008"))
|
||||
{
|
||||
Name = $"Schedule Padded Test Channel {suffix}",
|
||||
Number = withFallback ? "9" : "8",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = $"Schedule Padded Test Schedule {suffix}",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private static async Task<PlayoutReferenceData> GetSchedulePaddedReferenceData(TvContext dbContext, int playoutId)
|
||||
{
|
||||
Channel channel = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.Where(c => c.Playouts.Any(p => p.Id == playoutId))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
ProgramSchedule programSchedule = await dbContext.ProgramSchedules
|
||||
.AsNoTracking()
|
||||
.Where(ps => ps.Playouts.Any(p => p.Id == playoutId))
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.Collection)
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.MediaItem)
|
||||
.Include(ps => ps.Items)
|
||||
.ThenInclude(psi => psi.FallbackFiller)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return new PlayoutReferenceData(
|
||||
channel,
|
||||
Option<Deco>.None,
|
||||
[],
|
||||
[],
|
||||
programSchedule,
|
||||
[],
|
||||
[],
|
||||
TimeSpan.Zero);
|
||||
}
|
||||
|
||||
// #392: schedule-level pad + offline advance for a NON-One scheduler (Flood / Duration / Multiple).
|
||||
// Same shape as SeedSchedulePaddedData (no item-level Pad filler, no FallbackFiller → offline), only the
|
||||
// ProgramScheduleItem subtype differs. Reuses GetSchedulePaddedReferenceData for the build query.
|
||||
private async Task<List<PlayoutItem>> BuildSchedulePaddedModePlayout(string mode)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
int playoutId = await SeedSchedulePaddedModeData(cancellationToken, mode);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return buildResult.AddedItems;
|
||||
}
|
||||
|
||||
private async Task<int> SeedSchedulePaddedModeData(CancellationToken cancellationToken, string mode)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
// Per-mode suffix + GUID keep unique constraints from colliding across fixtures in the shared DB.
|
||||
string guid = mode switch
|
||||
{
|
||||
"Flood" => "00000000-0000-0000-0000-00000000000a",
|
||||
"Duration" => "00000000-0000-0000-0000-00000000000b",
|
||||
"Multiple" => "00000000-0000-0000-0000-00000000000c",
|
||||
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
|
||||
};
|
||||
|
||||
var path = new LibraryPath { Path = $"Schedule Padded Mode LibraryPath {mode}" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Sub-15-min durations (7/11/13) so each padded item occupies exactly one :15 slot. This makes a
|
||||
// whole :15-multiple block (Duration's 3h) tile exactly with no leftover — Duration therefore never
|
||||
// packs a final item unpadded to fill the block (its fill-the-duration contract legitimately
|
||||
// overrides per-item clock-pad when content straddles a :15, which would obscure the pad/seam signal
|
||||
// this test is pinning). Every mode then holds the same strict boundary invariant.
|
||||
int[] durationsMinutes = [7, 11, 13];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
movies.Add(new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Schedule Padded Mode Movie {mode} {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = $"Schedule Padded Mode Content Collection {mode}",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
ProgramScheduleItem scheduleItem = mode switch
|
||||
{
|
||||
"Flood" => new ProgramScheduleItemFlood
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
"Duration" => new ProgramScheduleItemDuration
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
PlayoutDuration = TimeSpan.FromHours(3),
|
||||
TailMode = TailMode.Offline,
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
"Multiple" => new ProgramScheduleItemMultiple
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological
|
||||
},
|
||||
_ => throw new ArgumentException($"Unsupported mode {mode}", nameof(mode))
|
||||
};
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = $"Schedule Padded Mode FFmpeg Profile {mode}" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse(guid))
|
||||
{
|
||||
Name = $"Schedule Padded Mode Channel {mode}",
|
||||
Number = mode switch { "Flood" => "10", "Duration" => "11", _ => "12" },
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = $"Schedule Padded Mode Schedule {mode}",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
// Regression harness for the Fill-With-Group DeepCopy defect: same shape as
|
||||
// SeedSchedulePaddedModeData (off-boundary durations, no item-level Pad filler, no FallbackFiller
|
||||
// -> offline), except the single ProgramScheduleItemMultiple sets FillWithGroupMode so PlayoutBuilder
|
||||
// schedules it via a synthesized (DeepCopy'd) fake schedule item instead of the original.
|
||||
private async Task<int> SeedSchedulePaddedFillWithGroupData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Schedule Padded FillWithGroup LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Off-boundary durations (22/37/52 min), same as SeedSchedulePaddedData, so padding to :15 is visible.
|
||||
int[] durationsMinutes = [22, 37, 52];
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= 3; i++)
|
||||
{
|
||||
movies.Add(new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(durationsMinutes[i - 1]) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new() { Title = $"Schedule Padded FillWithGroup Movie {i:D2}", ReleaseDate = new DateTime(2020, 1, 1).AddDays(i) }
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var contentCollection = new Collection
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Content Collection",
|
||||
MediaItems = movies.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddAsync(contentCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItem = new ProgramScheduleItemMultiple
|
||||
{
|
||||
Collection = contentCollection,
|
||||
CollectionId = contentCollection.Id,
|
||||
CollectionType = CollectionType.Collection,
|
||||
MultipleMode = MultipleMode.Count,
|
||||
Count = "3",
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
FillWithGroupMode = FillWithGroupMode.FillWithOrderedGroups
|
||||
};
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem> { scheduleItem };
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Schedule Padded FillWithGroup FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-00000000000d"))
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Channel",
|
||||
Number = "13",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule
|
||||
{
|
||||
Name = "Schedule Padded FillWithGroup Schedule",
|
||||
Items = scheduleItems,
|
||||
PadToNearestMinute = 15
|
||||
};
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return playout.Id;
|
||||
}
|
||||
|
||||
private async Task<List<PlayoutItem>> BuildSchedulePaddedFillWithGroupPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
int playoutId = await SeedSchedulePaddedFillWithGroupData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetSchedulePaddedReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Reset,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return buildResult.AddedItems;
|
||||
}
|
||||
|
||||
// --- Block builder ---
|
||||
//
|
||||
// BlockPlayoutBuilder maps template times-of-day to absolute instants via
|
||||
|
||||
@@ -66,7 +66,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
},
|
||||
new List<MediaChapter>(),
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
},
|
||||
new List<MediaChapter> { new() },
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(1);
|
||||
}
|
||||
@@ -192,7 +192,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(60) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(3);
|
||||
playoutItems[0].MediaItemId.ShouldBe(1);
|
||||
@@ -284,7 +284,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -392,7 +392,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -501,7 +501,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(6);
|
||||
|
||||
@@ -611,7 +611,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new() { StartTime = TimeSpan.FromMinutes(6), EndTime = TimeSpan.FromMinutes(45) }
|
||||
},
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
@@ -719,7 +719,7 @@ public class PlayoutModeSchedulerBaseTests : SchedulerTestBase
|
||||
new MediaChapter { StartTime = TimeSpan.FromMinutes(30), EndTime = TimeSpan.FromMinutes(45) }
|
||||
],
|
||||
new PlayoutBuildWarnings(),
|
||||
_cancellationToken);
|
||||
_cancellationToken).Items;
|
||||
|
||||
playoutItems.Count.ShouldBe(5);
|
||||
|
||||
|
||||
@@ -13,4 +13,5 @@ public record ProgramScheduleResponseModel(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ProgramSchedule : IVersionedAggregate
|
||||
public bool ShuffleScheduleItems { get; set; }
|
||||
public bool RandomStartPoint { get; set; }
|
||||
public FixedStartTimeBehavior FixedStartTimeBehavior { get; set; }
|
||||
public int? PadToNearestMinute { get; set; }
|
||||
public List<ProgramScheduleItem> Items { get; set; }
|
||||
public List<Playout> Playouts { get; set; }
|
||||
public List<ProgramScheduleAlternate> ProgramScheduleAlternates { get; set; }
|
||||
|
||||
@@ -115,13 +115,36 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
PlayoutBuildResult result,
|
||||
PlayoutBuildMode mode,
|
||||
PlayoutParameters parameters,
|
||||
CancellationToken cancellationToken) =>
|
||||
mode switch
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
|
||||
// (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
|
||||
if (referenceData.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = referenceData.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
|
||||
{
|
||||
if (alternate.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = alternate.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mode switch
|
||||
{
|
||||
PlayoutBuildMode.Refresh => RefreshPlayout(playout, referenceData, result, parameters, cancellationToken),
|
||||
PlayoutBuildMode.Reset => ResetPlayout(playout, referenceData, result, parameters, cancellationToken),
|
||||
_ => ContinuePlayout(playout, referenceData, result, parameters, cancellationToken)
|
||||
};
|
||||
}
|
||||
|
||||
internal async Task<Either<BaseError, PlayoutBuildResult>> Build(
|
||||
Playout playout,
|
||||
@@ -702,6 +725,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
}
|
||||
|
||||
var copyScheduleItem = scheduleItem.DeepCopy();
|
||||
// DeepCopy uses Newtonsoft serialization, and ProgramSchedule is [JsonIgnore]'d there,
|
||||
// so the reverse nav is lost on the copy. Reassign it so schedule-level settings
|
||||
// (e.g. PadToNearestMinute) remain readable in AddFiller for Fill-With-Group items.
|
||||
copyScheduleItem.ProgramSchedule = scheduleItem.ProgramSchedule;
|
||||
copyScheduleItem.CollectionType = key.CollectionType;
|
||||
copyScheduleItem.MediaItemId = key.MediaItemId;
|
||||
copyScheduleItem.FakeCollectionKey = key.FakeCollectionKey;
|
||||
@@ -806,6 +833,12 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
|
||||
var timeCount = new Dictionary<DateTimeOffset, int>();
|
||||
|
||||
// #392: exact clock-boundary target the LAST scheduled item advanced CurrentTime to when a
|
||||
// schedule-level pad left an offline gap (no filler). The post-loop anchor clamp reads this by
|
||||
// exact CurrentTime equality so the offline advance survives the day seam — precise (tied to the
|
||||
// real offline-pad signal), not a magnitude heuristic.
|
||||
DateTimeOffset? lastClockPadOfflineTarget = null;
|
||||
|
||||
// loop until we're done filling the desired amount of time
|
||||
while (playoutBuilderState.CurrentTime < playoutFinish && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
@@ -880,6 +913,10 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
(PlayoutBuilderState nextState, List<PlayoutItem> playoutItems, PlayoutBuildWarnings warnings) =
|
||||
schedulerResult;
|
||||
|
||||
// #392: remember the last scheduler's offline-pad target (null unless the final scheduled item
|
||||
// advanced CurrentTime to a clock boundary with no filler); the anchor clamp below reads it.
|
||||
lastClockPadOfflineTarget = schedulerResult.ClockPadOfflineTarget;
|
||||
|
||||
result.Warnings.Merge(warnings);
|
||||
|
||||
// if we completed a multiple/duration block, move to the next fill group
|
||||
@@ -924,7 +961,21 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
DateTimeOffset maxStartTime = result.AddedItems.Max(i => i.FinishOffset);
|
||||
if (maxStartTime < playoutBuilderState.CurrentTime)
|
||||
{
|
||||
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
|
||||
// #392: a schedule-level clock pad can legitimately advance CurrentTime past the last added
|
||||
// item with NO corresponding filler item (an intentional offline gap up to the next clock
|
||||
// boundary). Exempt the clamp ONLY when CurrentTime is exactly the offline-pad target the
|
||||
// final scheduled item recorded — a precise signal, not a magnitude heuristic. Any other
|
||||
// advance (a Duration/Flood/Multiple block ending short of its natural end, a hard-stop, a
|
||||
// tail-filler advance) changes CurrentTime away from that target, so it still clamps back to
|
||||
// the real content end and persists the correct NextStart.
|
||||
bool isOfflinePadAdvance =
|
||||
lastClockPadOfflineTarget is { } offlinePadTarget &&
|
||||
playoutBuilderState.CurrentTime == offlinePadTarget;
|
||||
|
||||
if (!isOfflinePadAdvance)
|
||||
{
|
||||
playoutBuilderState = playoutBuilderState with { CurrentTime = maxStartTime };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ErsatzTV.Core.Domain;
|
||||
@@ -279,7 +279,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
PlayoutBuilder.DisplayTitle(mediaItem),
|
||||
startTime);
|
||||
|
||||
internal List<PlayoutItem> AddFiller(
|
||||
internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
@@ -290,6 +290,8 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
{
|
||||
var result = new List<PlayoutItem>();
|
||||
|
||||
DateTimeOffset? offlinePadTarget = null;
|
||||
|
||||
var allFiller = Optional(scheduleItem.PreRollFiller)
|
||||
.Append(Optional(scheduleItem.MidRollFiller))
|
||||
.Append(Optional(scheduleItem.PostRollFiller))
|
||||
@@ -299,7 +301,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
if (allFiller.Count(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue) > 1)
|
||||
{
|
||||
Logger.LogError("Multiple pad-to-nearest-minute values are invalid; no filler will be used");
|
||||
return [playoutItem];
|
||||
return ([playoutItem], null);
|
||||
}
|
||||
|
||||
// missing pad-to-nearest-minute value is invalid; use no filler
|
||||
@@ -310,7 +312,7 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
Logger.LogError(
|
||||
"Pad filler ({Filler}) without pad-to-nearest-minute value is invalid; no filler will be used",
|
||||
invalidPadFiller.Name);
|
||||
return [playoutItem];
|
||||
return ([playoutItem], null);
|
||||
}
|
||||
|
||||
List<MediaChapter> effectiveChapters = chapters;
|
||||
@@ -580,6 +582,48 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
}
|
||||
}
|
||||
|
||||
// #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
|
||||
// wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
|
||||
// FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
|
||||
// advances the build clock to the boundary (leaving an offline gap).
|
||||
bool itemHasPadFiller =
|
||||
allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
|
||||
if (!itemHasPadFiller &&
|
||||
scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
|
||||
schedulePadMinutes > 0)
|
||||
{
|
||||
TimeSpan totalDuration = result.Aggregate(
|
||||
TimeSpan.Zero,
|
||||
(acc, i) => acc + (i.FinishOffset - i.StartOffset));
|
||||
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, schedulePadMinutes);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
if (remainingToFill > TimeSpan.Zero)
|
||||
{
|
||||
Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
|
||||
playoutBuilderState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
remainingToFill,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeFallback.IsSome)
|
||||
{
|
||||
foreach (PlayoutItem fallbackItem in maybeFallback)
|
||||
{
|
||||
result.Add(fallbackItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No fallback content: leave an offline gap up to the boundary.
|
||||
offlinePadTarget = targetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// after all non-padded filler has been added, figure out padding
|
||||
foreach (FillerPreset padFiller in Optional(
|
||||
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))
|
||||
@@ -601,29 +645,9 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
totalDuration += TimeSpan.FromTicks(filteredChapters.Sum(c => (c.EndTime - c.StartTime).Ticks));
|
||||
}
|
||||
|
||||
int currentMinute = (playoutItem.StartOffset + totalDuration).Minute;
|
||||
// ReSharper disable once PossibleInvalidOperationException
|
||||
int targetMinute = (currentMinute + padFiller.PadToNearestMinute.Value - 1) /
|
||||
padFiller.PadToNearestMinute.Value * padFiller.PadToNearestMinute.Value;
|
||||
|
||||
DateTimeOffset almostTargetTime = playoutItem.StartOffset + totalDuration -
|
||||
TimeSpan.FromMinutes(currentMinute) +
|
||||
TimeSpan.FromMinutes(targetMinute);
|
||||
|
||||
var targetTime = new DateTimeOffset(
|
||||
almostTargetTime.Year,
|
||||
almostTargetTime.Month,
|
||||
almostTargetTime.Day,
|
||||
almostTargetTime.Hour,
|
||||
almostTargetTime.Minute,
|
||||
0,
|
||||
almostTargetTime.Offset);
|
||||
|
||||
// ensure filler works for content less than one minute
|
||||
if (targetTime <= playoutItem.StartOffset + totalDuration)
|
||||
{
|
||||
targetTime = targetTime.AddMinutes(padFiller.PadToNearestMinute.Value);
|
||||
}
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
|
||||
@@ -763,7 +787,40 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
|
||||
currentTime = item.FinishOffset;
|
||||
}
|
||||
|
||||
return result;
|
||||
return (result, offlinePadTarget);
|
||||
}
|
||||
|
||||
// #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
|
||||
// Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
|
||||
// already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
|
||||
private static DateTimeOffset ComputePadBoundary(
|
||||
DateTimeOffset blockStart,
|
||||
TimeSpan totalDuration,
|
||||
int padToNearestMinute)
|
||||
{
|
||||
int currentMinute = (blockStart + totalDuration).Minute;
|
||||
int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;
|
||||
|
||||
DateTimeOffset almostTargetTime = blockStart + totalDuration -
|
||||
TimeSpan.FromMinutes(currentMinute) +
|
||||
TimeSpan.FromMinutes(targetMinute);
|
||||
|
||||
var targetTime = new DateTimeOffset(
|
||||
almostTargetTime.Year,
|
||||
almostTargetTime.Month,
|
||||
almostTargetTime.Day,
|
||||
almostTargetTime.Hour,
|
||||
almostTargetTime.Minute,
|
||||
0,
|
||||
almostTargetTime.Offset);
|
||||
|
||||
// ensure filler works for content less than one interval (and for content already on a boundary)
|
||||
if (targetTime <= blockStart + totalDuration)
|
||||
{
|
||||
targetTime = targetTime.AddMinutes(padToNearestMinute);
|
||||
}
|
||||
|
||||
return targetTime;
|
||||
}
|
||||
|
||||
private List<PlayoutItem> AddCountFiller(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -36,6 +36,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
var willFinishInTime = true;
|
||||
Option<DateTimeOffset> durationUntil = None;
|
||||
var discardAttempts = 0;
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
@@ -200,7 +201,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
enumeratorStates.Add(key, enumerator.State.Clone());
|
||||
}
|
||||
|
||||
List<PlayoutItem> maybePlayoutItems = AddFiller(
|
||||
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -219,6 +220,12 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
// }
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? itemClockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller)
|
||||
{
|
||||
itemEndTimeWithFiller = durPadTarget;
|
||||
itemClockPadOfflineTarget = durPadTarget;
|
||||
}
|
||||
|
||||
willFinishInTime = itemStartTime > durationFinish ||
|
||||
itemEndTimeWithFiller <= durationFinish;
|
||||
@@ -226,6 +233,7 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
{
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
playoutItems.AddRange(maybePlayoutItems);
|
||||
clockPadOfflineTarget = itemClockPadOfflineTarget;
|
||||
|
||||
nextState = nextState with
|
||||
{
|
||||
@@ -350,7 +358,10 @@ public class PlayoutModeSchedulerDuration(ILogger logger)
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Duration";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -23,6 +23,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState;
|
||||
var willFinishInTime = true;
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
IMediaCollectionEnumerator contentEnumerator =
|
||||
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
|
||||
@@ -112,7 +113,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
enumeratorStates.Add(key, enumerator.State.Clone());
|
||||
}
|
||||
|
||||
List<PlayoutItem> maybePlayoutItems = AddFiller(
|
||||
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -122,6 +123,12 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
cancellationToken);
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? itemClockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
|
||||
{
|
||||
itemEndTimeWithFiller = floodPadTarget;
|
||||
itemClockPadOfflineTarget = floodPadTarget;
|
||||
}
|
||||
|
||||
// if the next schedule item is supposed to start during this item,
|
||||
// don't schedule this item and just move on
|
||||
@@ -131,6 +138,7 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
if (willFinishInTime)
|
||||
{
|
||||
playoutItems.AddRange(maybePlayoutItems);
|
||||
clockPadOfflineTarget = itemClockPadOfflineTarget;
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
nextState = nextState with
|
||||
@@ -204,7 +212,10 @@ public class PlayoutModeSchedulerFlood(ILogger logger) : PlayoutModeSchedulerBas
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Flood";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -77,6 +77,8 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
}
|
||||
}
|
||||
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
|
||||
while (contentEnumerator.Current.IsSome && nextState.MultipleRemaining > 0 &&
|
||||
nextState.CurrentTime < hardStop)
|
||||
{
|
||||
@@ -135,19 +137,30 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
|
||||
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
|
||||
|
||||
playoutItems.AddRange(
|
||||
AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
playoutItem,
|
||||
itemChapters,
|
||||
warnings,
|
||||
cancellationToken));
|
||||
(List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
playoutItem,
|
||||
itemChapters,
|
||||
warnings,
|
||||
cancellationToken);
|
||||
playoutItems.AddRange(filled);
|
||||
|
||||
DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
|
||||
{
|
||||
multipleEnd = mulPadTarget;
|
||||
clockPadOfflineTarget = mulPadTarget;
|
||||
}
|
||||
else
|
||||
{
|
||||
clockPadOfflineTarget = null;
|
||||
}
|
||||
|
||||
nextState = nextState with
|
||||
{
|
||||
CurrentTime = playoutItems.Max(pi => pi.FinishOffset),
|
||||
CurrentTime = multipleEnd,
|
||||
MultipleRemaining = nextState.MultipleRemaining.Map(i => i - 1),
|
||||
|
||||
// only bump guide group if we don't have a custom title
|
||||
@@ -205,7 +218,10 @@ public class PlayoutModeSchedulerMultiple(Map<CollectionKey, int> collectionItem
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
protected override string SchedulingContextName => "Multiple";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
@@ -82,7 +82,7 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
});
|
||||
}
|
||||
|
||||
List<PlayoutItem> playoutItems = AddFiller(
|
||||
(List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
playoutBuilderState,
|
||||
collectionEnumerators,
|
||||
scheduleItem,
|
||||
@@ -91,9 +91,17 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
warnings,
|
||||
cancellationToken);
|
||||
|
||||
DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
DateTimeOffset? clockPadOfflineTarget = null;
|
||||
if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
|
||||
{
|
||||
oneEnd = onePadTarget;
|
||||
clockPadOfflineTarget = onePadTarget;
|
||||
}
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState with
|
||||
{
|
||||
CurrentTime = playoutItems.Max(pi => pi.FinishOffset)
|
||||
CurrentTime = oneEnd
|
||||
};
|
||||
|
||||
nextState.ScheduleItemsEnumerator.MoveNext();
|
||||
@@ -133,7 +141,10 @@ public class PlayoutModeSchedulerOne(ILogger logger) : PlayoutModeSchedulerBase<
|
||||
|
||||
nextState = nextState with { NextGuideGroup = nextState.IncrementGuideGroup };
|
||||
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings);
|
||||
return new PlayoutSchedulerResult(nextState, playoutItems, warnings)
|
||||
{
|
||||
ClockPadOfflineTarget = clockPadOfflineTarget
|
||||
};
|
||||
}
|
||||
|
||||
return new PlayoutSchedulerResult(playoutBuilderState, [], warnings);
|
||||
|
||||
@@ -5,4 +5,11 @@ namespace ErsatzTV.Core.Scheduling;
|
||||
public record PlayoutSchedulerResult(
|
||||
PlayoutBuilderState State,
|
||||
List<PlayoutItem> PlayoutItems,
|
||||
PlayoutBuildWarnings Warnings);
|
||||
PlayoutBuildWarnings Warnings)
|
||||
{
|
||||
// #392: the exact clock-boundary target the last scheduled item advanced CurrentTime to when a
|
||||
// schedule-level pad left an OFFLINE gap (no filler emitted). Non-null ONLY for such an advance; the
|
||||
// day-boundary anchor clamp in PlayoutBuilder reads it (by exact CurrentTime equality) so an offline
|
||||
// pad advance survives the seam. Transient/in-memory only — never serialized to the PlayoutAnchor.
|
||||
public DateTimeOffset? ClockPadOfflineTarget { get; init; }
|
||||
}
|
||||
|
||||
+7306
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ProgramSchedule_PadToNearestMinute : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2485,6 +2485,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("utf8mb4_general_ci");
|
||||
|
||||
b.Property<int?>("PadToNearestMinute")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("RandomStartPoint")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
|
||||
+7131
File diff suppressed because it is too large
Load Diff
+28
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Add_ProgramSchedule_PadToNearestMinute : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2378,6 +2378,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
.HasColumnType("varchar(50)")
|
||||
.UseCollation("NOCASE");
|
||||
|
||||
b.Property<int?>("PadToNearestMinute")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("RandomStartPoint")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ public class RootWriterForceVersionTests
|
||||
|
||||
// Name-only change → no playout refresh; the force-write must not 500 on the concurrent bump.
|
||||
Either<BaseError, UpdateProgramScheduleResult> result = await handler.Handle(
|
||||
new UpdateProgramSchedule(1, "After", false, false, false, false, default),
|
||||
new UpdateProgramSchedule(1, "After", false, false, false, false, default, null),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
@@ -30,6 +30,55 @@ public class ProgramScheduleHandlerTests
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
[Test]
|
||||
public async Task Create_and_update_persist_PadToNearestMinute()
|
||||
{
|
||||
var createHandler = new CreateProgramScheduleHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, CreateProgramScheduleResult> createResult = await createHandler.Handle(
|
||||
new CreateProgramSchedule(
|
||||
"Pad Test",
|
||||
KeepMultiPartEpisodesTogether: true,
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: false,
|
||||
RandomStartPoint: false,
|
||||
FixedStartTimeBehavior.Flexible,
|
||||
PadToNearestMinute: 30),
|
||||
CancellationToken.None);
|
||||
|
||||
int scheduleId = createResult.Match(Left: _ => throw new AssertionException("Expected a Right result"), Right: r => r.ProgramScheduleId);
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
ProgramSchedule? schedule = await context.ProgramSchedules.FindAsync(scheduleId);
|
||||
schedule.ShouldNotBeNull();
|
||||
schedule.PadToNearestMinute.ShouldBe(30);
|
||||
}
|
||||
|
||||
var updateHandler = new UpdateProgramScheduleHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, UpdateProgramScheduleResult> updateResult = await updateHandler.Handle(
|
||||
new UpdateProgramSchedule(
|
||||
scheduleId,
|
||||
"Pad Test",
|
||||
KeepMultiPartEpisodesTogether: true,
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: false,
|
||||
RandomStartPoint: false,
|
||||
FixedStartTimeBehavior.Flexible,
|
||||
PadToNearestMinute: null),
|
||||
CancellationToken.None);
|
||||
|
||||
updateResult.IsRight.ShouldBeTrue();
|
||||
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
ProgramSchedule? schedule = await context.ProgramSchedules.FindAsync(scheduleId);
|
||||
schedule.ShouldNotBeNull();
|
||||
schedule.PadToNearestMinute.ShouldBeNull();
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_NotFoundError_When_Schedule_Missing()
|
||||
{
|
||||
@@ -125,7 +174,8 @@ public class ProgramScheduleHandlerTests
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: false,
|
||||
RandomStartPoint: false,
|
||||
FixedStartTimeBehavior.Flexible);
|
||||
FixedStartTimeBehavior.Flexible,
|
||||
PadToNearestMinute: null);
|
||||
|
||||
private static AddProgramScheduleItem MakeAdd(int scheduleId, CollectionType collectionType) =>
|
||||
new(
|
||||
|
||||
@@ -1384,7 +1384,7 @@ public class PlayoutControllerTests
|
||||
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, 0);
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, null, 0);
|
||||
|
||||
private static TemplateViewModel MakeTemplateViewModel(int id) =>
|
||||
new(id, 1, "Group", $"Template {id}", 0);
|
||||
|
||||
@@ -80,7 +80,7 @@ public class ScheduleControllerTests
|
||||
created.StatusCode.ShouldBe(201);
|
||||
created.Location.ShouldBe("/api/v1/schedules/5");
|
||||
created.Value.ShouldBe(
|
||||
new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
|
||||
new ProgramScheduleResponseModel(5, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -130,7 +130,7 @@ public class ScheduleControllerTests
|
||||
IActionResult result = await _controller.Update(7, MakeUpdateScheduleRequest("Updated"), CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
|
||||
new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible));
|
||||
new ProgramScheduleResponseModel(7, "Updated", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateProgramSchedule>(c => c.ProgramScheduleId == 7 && c.Name == "Updated"),
|
||||
Arg.Any<CancellationToken>());
|
||||
@@ -182,7 +182,7 @@ public class ScheduleControllerTests
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(
|
||||
new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible));
|
||||
new ProgramScheduleResponseModel(4, "Daily", true, true, false, false, FixedStartTimeBehavior.Flexible, null));
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -393,7 +393,8 @@ public class ScheduleControllerTests
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: true,
|
||||
RandomStartPoint: true,
|
||||
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible);
|
||||
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible,
|
||||
PadToNearestMinute: null);
|
||||
|
||||
private static UpdateScheduleRequest MakeUpdateScheduleRequest(string name) =>
|
||||
new(
|
||||
@@ -402,7 +403,8 @@ public class ScheduleControllerTests
|
||||
TreatCollectionsAsShows: true,
|
||||
ShuffleScheduleItems: true,
|
||||
RandomStartPoint: true,
|
||||
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible);
|
||||
FixedStartTimeBehavior: FixedStartTimeBehavior.Flexible,
|
||||
PadToNearestMinute: null);
|
||||
|
||||
private static ScheduleItemRequest MakeItemRequest(PlayoutMode playoutMode) =>
|
||||
new(
|
||||
@@ -446,7 +448,7 @@ public class ScheduleControllerTests
|
||||
SubtitleMode: null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeSchedule(int id, string name, int version = 0) =>
|
||||
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, version);
|
||||
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, null, version);
|
||||
|
||||
private static ProgramScheduleItemOneViewModel MakeOneItem(int id) =>
|
||||
new(
|
||||
|
||||
@@ -9,7 +9,8 @@ public record CreateScheduleRequest(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior)
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute)
|
||||
{
|
||||
public CreateProgramSchedule ToCreateCommand() =>
|
||||
new(
|
||||
@@ -18,5 +19,6 @@ public record CreateScheduleRequest(
|
||||
TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior,
|
||||
PadToNearestMinute);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ public record UpdateScheduleRequest(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior)
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute)
|
||||
{
|
||||
public UpdateProgramSchedule ToCommand(int id) =>
|
||||
new(
|
||||
@@ -19,5 +20,6 @@ public record UpdateScheduleRequest(
|
||||
TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior,
|
||||
PadToNearestMinute);
|
||||
}
|
||||
|
||||
@@ -228,5 +228,6 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
vm.TreatCollectionsAsShows,
|
||||
vm.ShuffleScheduleItems,
|
||||
vm.RandomStartPoint,
|
||||
vm.FixedStartTimeBehavior);
|
||||
vm.FixedStartTimeBehavior,
|
||||
vm.PadToNearestMinute);
|
||||
}
|
||||
|
||||
@@ -25580,7 +25580,8 @@
|
||||
"treatCollectionsAsShows",
|
||||
"shuffleScheduleItems",
|
||||
"randomStartPoint",
|
||||
"fixedStartTimeBehavior"
|
||||
"fixedStartTimeBehavior",
|
||||
"padToNearestMinute"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -25604,6 +25605,13 @@
|
||||
},
|
||||
"fixedStartTimeBehavior": {
|
||||
"$ref": "#/components/schemas/FixedStartTimeBehavior"
|
||||
},
|
||||
"padToNearestMinute": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -29198,7 +29206,8 @@
|
||||
"treatCollectionsAsShows",
|
||||
"shuffleScheduleItems",
|
||||
"randomStartPoint",
|
||||
"fixedStartTimeBehavior"
|
||||
"fixedStartTimeBehavior",
|
||||
"padToNearestMinute"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -29223,6 +29232,13 @@
|
||||
},
|
||||
"fixedStartTimeBehavior": {
|
||||
"$ref": "#/components/schemas/FixedStartTimeBehavior"
|
||||
},
|
||||
"padToNearestMinute": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -32351,7 +32367,8 @@
|
||||
"treatCollectionsAsShows",
|
||||
"shuffleScheduleItems",
|
||||
"randomStartPoint",
|
||||
"fixedStartTimeBehavior"
|
||||
"fixedStartTimeBehavior",
|
||||
"padToNearestMinute"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32375,6 +32392,13 @@
|
||||
},
|
||||
"fixedStartTimeBehavior": {
|
||||
"$ref": "#/components/schemas/FixedStartTimeBehavior"
|
||||
},
|
||||
"padToNearestMinute": {
|
||||
"type": [
|
||||
"null",
|
||||
"integer"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
const KIND_OPTIONS = ["Pre-Roll", "Mid-Roll", "Post-Roll", "Tail", "Fallback"];
|
||||
const MODE_OPTIONS = ["Duration", "Count", "Pad", "Random Count"];
|
||||
const PAD_OPTIONS = ["5", "10", "15", "30"];
|
||||
const PAD_OPTIONS = ["5", "10", "15", "30", "60"];
|
||||
const COLLECTION_TYPE_OPTIONS = ["Collection", "Television Show", "Television Season", "Artist", "Multi Collection", "Smart Collection", "Playlist"];
|
||||
|
||||
function Row({ label, help, control = 320, first = false, children }) {
|
||||
|
||||
@@ -3561,3 +3561,28 @@ no scheduling logic of its own. So "Scripted is un-golden-able" conflates two di
|
||||
The #381 "documented decision" arm correctly deferred the *pipeline* golden; it overstated the case by writing
|
||||
off engine-level coverage too. Scripted scheduling *behavior* is now covered in-process; only the
|
||||
external-process pipeline remains #563's.
|
||||
|
||||
## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)
|
||||
|
||||
`key: sched.clock-padding-schedule-toggle` · `status: active` · `since: 2026-07-22` · `supersedes: none` · `superseded-by: none`
|
||||
**Rule:** A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388).
|
||||
**Signals:** per-schedule clock padding, PadToNearestMinute on ProgramSchedule, offline gap on pad, ClockPadOfflineTarget, synthetic Pad without FillerPreset · paths: `PlayoutModeSchedulerBase.AddFiller`, `PlayoutSchedulerResult.ClockPadOfflineTarget`, `FallbackFillerForPad`, `/app/schedules` · issues: #392, #77, #388
|
||||
**Mechanics:** `PlayoutBuildGoldenTests` (One/Flood/Duration/Multiple clock-pad cases), midnight-crossing invariant tests
|
||||
|
||||
A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content
|
||||
item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing
|
||||
per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a
|
||||
self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad
|
||||
wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule
|
||||
item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch
|
||||
records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary,
|
||||
leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`,
|
||||
rendered as "Channel is Offline" at stream time). That advance is carried by a transient per-build
|
||||
`PlayoutSchedulerResult.ClockPadOfflineTarget` (never serialized — no anchor schema, no migration); the
|
||||
day-seam anchor clamp is exempted by exact equality with that target.
|
||||
|
||||
This is the per-schedule convenience layer deferred behind #388 in `sched.clock-padding-existing`; that
|
||||
record's per-item Pad-preset behavior is unchanged. Determinism needs no new anchor/seed state (the pad math
|
||||
is a pure function of offsets). Coverage is per-scheduler-mode (One/Flood/Duration/Multiple) via golden and
|
||||
invariant tests across midnight crossings. The SPA schedule editor exposes it as a 5/10/15/30/60 minute
|
||||
picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||||
|
||||
@@ -114,6 +114,7 @@ the link for rationale. Superseded/retired history lives in `archive/`. Regenera
|
||||
| `sched.autotune-per-channel-overrides` | Auto-Tune per-channel overrides reuse the Channel Builder's advanced-options DTO verbatim; per-source weights and bug-colour logo are deferred to #425. | 2026-07-17 | [link](../decisions.md#2026-07-17--auto-tune-per-channel-overrides-reuse-the-channel-builder-advanced-options-dto-weights--bug-colour-logo-split-out-to-425-385) |
|
||||
| `sched.autotune-per-source-weights` | Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70's MultiCollection/SmartCollection machinery, not a post-hoc PUT. | 2026-07-18 | [link](../decisions.md#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425) |
|
||||
| `sched.clock-padding-existing` | Clock-boundary padding already exists via `FillerPreset`'s `FillerMode.Pad` (Classic) and `pad_to_next`/`pad_until` (Sequential/YAML); #77 is closed as verified+documented, not built new, with a one-click per-channel toggle deferred behind the #388 design-system epic. | 2026-07-17 | [link](../decisions.md#2026-07-17--clock-boundary-schedule-padding-already-exists-fillermodepad-77-verified-convenience-toggle-deferred) |
|
||||
| `sched.clock-padding-schedule-toggle` | A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content item up to the next N-minute clock boundary without a hand-wired Pad `FillerPreset`, by reusing the existing per-content-item Pad path in `PlayoutModeSchedulerBase.AddFiller`. It extends — does not supersede — `sched.clock-padding-existing` (#77/#388). | 2026-07-22 | [link](../decisions.md#2026-07-22--per-schedule-clock-boundary-padding-is-a-synthetic-content-less-pad-over-the-existing-per-episode-machinery-392) |
|
||||
| `sched.playbackorder-support-matrix` | Every build-time dispatch site logs a loud (non-fatal) warning on an unsupported `PlaybackOrder`, and a declared `PlaybackOrderSupport` matrix + partition tripwire test makes adding a new order safe by construction. | 2026-07-18 | [link](../decisions.md#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403) |
|
||||
| `sched.reshuffle-scoped-reset` | `POST /api/v1/playouts/{id}/reshuffle` runs `ErasePlayoutHistory` (reseeds `Playout.Seed` + clears anchors/rerun-history) then enqueues a scoped `Reset` build, so reshuffle always reseeds — even for the non-Classic kinds `Reset` alone wouldn't reseed; `Playout.Seed` is surfaced on list/detail DTOs as visible confirmation. | 2026-07-16 | [link](../decisions.md#2026-07-16--per-playout-reshuffle--scoped-reset-build-seed-surfaced-71) |
|
||||
| `sched.seasonal-scheduling-existing` | Seasonal/date-conditional scheduling already ships first-class via `IAlternateScheduleItem` (Classic `ProgramScheduleAlternate`, Block `PlayoutTemplate`) evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match in `Index` order, catch-all last); #73 is closed as already-implemented with a docs-only "seasonal/holiday" recipe added, not new code. | 2026-07-17 | [link](../decisions.md#2026-07-17--seasonal--date-conditional-scheduling-already-exists-alternate-schedules--playout-templates-73-closed-as-implemented) |
|
||||
|
||||
@@ -64,7 +64,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **DecoTemplate** | Time-of-day (`DecoTemplateItem.StartTime`/`EndTime`) calendar of `Deco`s, assigned to a playout via `PlayoutTemplate.DecoTemplateId` (same row as the Block-template assignment — one `PlayoutTemplate` entry carries both a `Template` and an optional `DecoTemplate`). | `DecoTemplate`, `DecoTemplateItem`, `DecoTemplateGroup` | `/app/deco-templates` |
|
||||
| **Default deco vs deco templates** | `Playout.DecoId` = one static deco for the whole playout; `PlayoutTemplate.DecoTemplateId` = a time-varying deco schedule. Both are optional and independent. | `Playout`, `PlayoutTemplate` | `/app/playouts/{id}/templates` |
|
||||
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
|
||||
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
|
||||
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) is the per-schedule convenience layer (#392): optional clock-boundary padding divisor (minutes); when set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller else offline. See `decisions.md` 2026-07-17 and 2026-07-22. | `FillerPreset` (`Pad`), `ProgramSchedule` (`PadToNearestMinute`) | `/app/filler-presets`, `/app/schedules` |
|
||||
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `WeightedShuffle` is offered as a Playback Order **only** on classic schedule items whose source is a MultiCollection (`web/src/schedules/itemRules.ts`, #404); the per-source weights themselves are edited at `/app/multi-collections` |
|
||||
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
|
||||
|
||||
@@ -0,0 +1,913 @@
|
||||
# Per-schedule clock-boundary padding toggle (#392) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or
|
||||
> superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a per-`ProgramSchedule` "pad every content item up to the next N-minute clock boundary" option
|
||||
that the Classic playout builder honors automatically — no hand-wired Pad `FillerPreset` — filling each gap
|
||||
with the schedule item's `FallbackFiller` if configured, else an offline gap; plus a 60-minute increment in
|
||||
the filler-preset editor.
|
||||
|
||||
**Architecture:** Reuse the existing per-episode Pad machinery. `AddFiller`
|
||||
(`PlayoutModeSchedulerBase`) already runs once per emitted content item. We add a **self-contained synthetic
|
||||
-pad branch** in `AddFiller` that engages only when the item has no own Pad filler and its parent schedule
|
||||
has `PadToNearestMinute` set; it computes the boundary target, fills with `FallbackFillerForPad`, and — when
|
||||
no fallback content exists — returns an offline target that each scheduler uses to advance the build clock
|
||||
(leaving an implicit offline gap, exactly as fixed-start items do). The schedule-level divisor reaches
|
||||
`AddFiller` by populating the (currently null) `ProgramScheduleItem.ProgramSchedule` reverse navigation once,
|
||||
centrally, in `PlayoutBuilder`.
|
||||
|
||||
**Tech Stack:** C#/.NET 10, EF Core (dual-provider Sqlite + MySql migrations), MediatR CQRS, LanguageExt,
|
||||
NUnit + Shouldly golden tests, ChicoryTV React SPA (Vite/TS), OpenAPI-generated TS types.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Work in the worktree `/Users/timothy/ersatztv/.claude/worktrees/392-clock-align` on branch
|
||||
`feat/392-clock-align-schedule` (off `origin/main`). Never commit in the shared tree.
|
||||
- DB model changes need a migration in **both** `ErsatzTV.Infrastructure.Sqlite` and
|
||||
`ErsatzTV.Infrastructure.MySql`, generated via `scripts/add-migration.sh <Name>` (never hand-authored
|
||||
snapshots).
|
||||
- Any `/api/*` DTO change must regenerate OpenAPI artifacts in the SAME diff: `dotnet build ErsatzTV.sln`
|
||||
→ `./scripts/update-openapi.sh` → `cd web && npm run generate:api`; commit
|
||||
`ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`.
|
||||
- Response DTOs carry a file-scoped `#nullable enable`; request DTOs do not.
|
||||
- All ProgramSchedule DTOs/records/VMs are **positional records** — add the new field in a CONSISTENT
|
||||
position (append to the end) across every one, or the positional constructors misalign.
|
||||
- **Never** set `ETV_UPDATE_PLAYOUT_GOLDENS` in CI; only locally to regenerate a golden, then review + commit it.
|
||||
- Before any push touching `.cs`: BOM-check the touched set (`git diff --name-only origin/main | grep '\.cs$'`
|
||||
then `xxd -p <f> | grep -c '^efbbbf'` must be 0) and run the format gate under `bash -c`.
|
||||
- Commit hooks in the worktree can misfire — commit with `git -c core.hooksPath=/dev/null commit`, then run
|
||||
the format/BOM checks manually.
|
||||
- Independent cross-model review is MANDATORY before push (migration + builder + API write-path).
|
||||
- Pad math is TZ-independent ONLY when the divisor divides 60 (all real IANA offsets are multiples of 15).
|
||||
Keep golden divisors at 15; the SPA offers only 5/10/15/30/60.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 60-minute increment in the filler-preset editor (SPA, standalone)
|
||||
|
||||
Independent of everything else — ship first.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/screens/FillerPresetsScreen.tsx:42`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: nothing other tasks depend on.
|
||||
|
||||
- [ ] **Step 1: Add 60 to `PAD_OPTIONS`.** Change line 42 from:
|
||||
|
||||
```tsx
|
||||
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
|
||||
```
|
||||
to:
|
||||
```tsx
|
||||
const PAD_OPTIONS = [5, 10, 15, 30, 60].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build the web app to verify no type/lint error.**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 3: Lint.**
|
||||
|
||||
Run: `cd web && npm run lint`
|
||||
Expected: no new errors in `FillerPresetsScreen.tsx`.
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add web/src/screens/FillerPresetsScreen.tsx
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(392): add 60-minute option to filler-preset pad increment"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `ProgramSchedule.PadToNearestMinute` column + dual-provider migration
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Core/Domain/ProgramSchedule.cs`
|
||||
- Generated: `ErsatzTV.Infrastructure.Sqlite/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)
|
||||
- Generated: `ErsatzTV.Infrastructure.MySql/Migrations/*_Add_ProgramSchedule_PadToNearestMinute.cs` (+ `.Designer.cs` + snapshot)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `ProgramSchedule.PadToNearestMinute` — `public int? PadToNearestMinute { get; set; }` (null = feature off).
|
||||
|
||||
- [ ] **Step 1: Add the property.** In `ErsatzTV.Core/Domain/ProgramSchedule.cs`, after
|
||||
`FixedStartTimeBehavior` (currently the last scalar before the nav collections), add:
|
||||
|
||||
```csharp
|
||||
public int? PadToNearestMinute { get; set; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Generate both migrations.** From the worktree root:
|
||||
|
||||
Run: `./scripts/add-migration.sh Add_ProgramSchedule_PadToNearestMinute`
|
||||
Expected: creates one migration pair under each provider's `Migrations/` folder and updates both
|
||||
`TvContextModelSnapshot.cs`. The generated `Up` should contain an `AddColumn<int>` on `ProgramSchedule`
|
||||
named `PadToNearestMinute`, `nullable: true`, no `defaultValue`.
|
||||
|
||||
- [ ] **Step 3: Verify the generated Up/Down.** Open both generated `*_Add_ProgramSchedule_PadToNearestMinute.cs`
|
||||
and confirm they match this shape (Sqlite `type: "INTEGER"`, MySql `type: "int"`):
|
||||
|
||||
```csharp
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule",
|
||||
type: "INTEGER", // MySql: "int"
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PadToNearestMinute",
|
||||
table: "ProgramSchedule");
|
||||
}
|
||||
```
|
||||
If EF emitted `defaultValue: 0` or `nullable: false`, fix it to the above (a nullable int must have neither).
|
||||
|
||||
- [ ] **Step 4: Build the two migration projects.**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Infrastructure.Sqlite ErsatzTV.Infrastructure.MySql`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 5: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Domain/ProgramSchedule.cs \
|
||||
ErsatzTV.Infrastructure.Sqlite/Migrations ErsatzTV.Infrastructure.MySql/Migrations
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(392): add ProgramSchedule.PadToNearestMinute column (dual-provider migration)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Expose `padToNearestMinute` through the REST API
|
||||
|
||||
Depends on Task 2. Adds the field to the read + write chains and regenerates OpenAPI.
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs`
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/Mapper.cs`
|
||||
- Modify: `ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs`
|
||||
- Modify: `ErsatzTV/Controllers/Api/ScheduleController.cs` (`ProjectToResponseModel`)
|
||||
- Modify: `ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs`
|
||||
- Modify: `ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs`
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs`
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs`
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`
|
||||
- Modify: `ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs`
|
||||
- Generated (regen, commit): `ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md`
|
||||
- Test: `ErsatzTV.Application.Tests` (or the existing schedule handler test project — grep for
|
||||
`UpdateProgramScheduleHandler`/`CreateProgramScheduleHandler` tests; if none exist, add a minimal one as below)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2).
|
||||
- Produces:
|
||||
- `ProgramScheduleResponseModel(..., FixedStartTimeBehavior, int? PadToNearestMinute)`
|
||||
- `ProgramScheduleViewModel(..., FixedStartTimeBehavior, int? PadToNearestMinute, int Version)` — note
|
||||
`PadToNearestMinute` goes **before** `Version` to match existing positional order (Version is last).
|
||||
- `CreateScheduleRequest`/`UpdateScheduleRequest`/`CreateProgramSchedule`/`UpdateProgramSchedule` each gain a
|
||||
trailing `int? PadToNearestMinute` positional member.
|
||||
- TS type `components['schemas']['ProgramScheduleResponseModel'].padToNearestMinute: number | null` and the
|
||||
matching Create/Update request schemas.
|
||||
|
||||
- [ ] **Step 1: Write a failing handler round-trip test.** Find the existing schedule handler tests (grep
|
||||
`FullyQualifiedName~ProgramSchedule` under `*.Tests`). If a `CreateProgramScheduleHandler`/`UpdateProgramScheduleHandler`
|
||||
test class exists, add a test there; otherwise create
|
||||
`ErsatzTV.Application.Tests/ProgramSchedules/ProgramSchedulePadToNearestMinuteTests.cs` mirroring the nearest
|
||||
existing handler test's setup (in-memory `TvContext` via the shared test fixture). The test asserts the field
|
||||
round-trips create→read and update→read:
|
||||
|
||||
```csharp
|
||||
[Test]
|
||||
public async Task Create_and_update_persist_PadToNearestMinute()
|
||||
{
|
||||
// Arrange: create a schedule with PadToNearestMinute = 30 via CreateProgramSchedule,
|
||||
// then load it and assert entity.PadToNearestMinute == 30;
|
||||
// then UpdateProgramSchedule with PadToNearestMinute = null and assert it clears to null.
|
||||
// (Mirror the arrange/act pattern of the nearest existing ProgramSchedule handler test.)
|
||||
}
|
||||
```
|
||||
|
||||
Keep it minimal and consistent with the existing handler-test style (do not invent a new harness).
|
||||
|
||||
- [ ] **Step 2: Run it — expect a COMPILE failure** (the new positional arg doesn't exist yet).
|
||||
|
||||
Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
|
||||
Expected: does not compile / FAIL.
|
||||
|
||||
- [ ] **Step 3: Add the field to the read chain.**
|
||||
|
||||
`ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs` — add `int? PadToNearestMinute` before `int Version`:
|
||||
```csharp
|
||||
public record ProgramScheduleViewModel(
|
||||
int Id,
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute,
|
||||
int Version);
|
||||
```
|
||||
|
||||
`ErsatzTV.Application/ProgramSchedules/Mapper.cs` — add the argument before `Version`:
|
||||
```csharp
|
||||
internal static ProgramScheduleViewModel ProjectToViewModel(ProgramSchedule programSchedule) =>
|
||||
new(
|
||||
programSchedule.Id,
|
||||
programSchedule.Name,
|
||||
programSchedule.KeepMultiPartEpisodesTogether,
|
||||
programSchedule.TreatCollectionsAsShows,
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior,
|
||||
programSchedule.PadToNearestMinute,
|
||||
programSchedule.Version);
|
||||
```
|
||||
|
||||
`ErsatzTV.Core/Api/Scheduling/ProgramScheduleResponseModel.cs` — append the member:
|
||||
```csharp
|
||||
public record ProgramScheduleResponseModel(
|
||||
int Id,
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute);
|
||||
```
|
||||
|
||||
`ErsatzTV/Controllers/Api/ScheduleController.cs` `ProjectToResponseModel` — append `vm.PadToNearestMinute`:
|
||||
```csharp
|
||||
private static ProgramScheduleResponseModel ProjectToResponseModel(ProgramScheduleViewModel vm) =>
|
||||
new(
|
||||
vm.Id,
|
||||
vm.Name,
|
||||
vm.KeepMultiPartEpisodesTogether,
|
||||
vm.TreatCollectionsAsShows,
|
||||
vm.ShuffleScheduleItems,
|
||||
vm.RandomStartPoint,
|
||||
vm.FixedStartTimeBehavior,
|
||||
vm.PadToNearestMinute);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the field to the write chain.**
|
||||
|
||||
`ErsatzTV/Controllers/Api/Requests/CreateScheduleRequest.cs` — append `int? PadToNearestMinute` to the record
|
||||
and to `ToCreateCommand()`:
|
||||
```csharp
|
||||
public record CreateScheduleRequest(
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute)
|
||||
{
|
||||
public CreateProgramSchedule ToCreateCommand() =>
|
||||
new(
|
||||
Name,
|
||||
KeepMultiPartEpisodesTogether,
|
||||
TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior,
|
||||
PadToNearestMinute);
|
||||
}
|
||||
```
|
||||
|
||||
`ErsatzTV/Controllers/Api/Requests/UpdateScheduleRequest.cs` — same, `ToCommand(int id)`:
|
||||
```csharp
|
||||
public record UpdateScheduleRequest(
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute)
|
||||
{
|
||||
public UpdateProgramSchedule ToCommand(int id) =>
|
||||
new(
|
||||
id,
|
||||
Name,
|
||||
KeepMultiPartEpisodesTogether,
|
||||
TreatCollectionsAsShows,
|
||||
ShuffleScheduleItems,
|
||||
RandomStartPoint,
|
||||
FixedStartTimeBehavior,
|
||||
PadToNearestMinute);
|
||||
}
|
||||
```
|
||||
|
||||
`ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs` — append the member:
|
||||
```csharp
|
||||
public record CreateProgramSchedule(
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, CreateProgramScheduleResult>>;
|
||||
```
|
||||
|
||||
`ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs`:
|
||||
```csharp
|
||||
public record UpdateProgramSchedule(
|
||||
int ProgramScheduleId,
|
||||
string Name,
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int? PadToNearestMinute) : IRequest<Either<BaseError, UpdateProgramScheduleResult>>;
|
||||
```
|
||||
|
||||
`CreateProgramScheduleHandler.cs` — set the field when building the entity (in the `new ProgramSchedule { ... }`
|
||||
initializer), normalizing a non-positive value to null so only meaningful divisors persist:
|
||||
```csharp
|
||||
PadToNearestMinute = request.PadToNearestMinute is int m && m > 0 ? m : null
|
||||
```
|
||||
(add as the last initializer member; keep the existing trailing members intact).
|
||||
|
||||
`UpdateProgramScheduleHandler.cs` `ApplyUpdateRequest` — add to BOTH the rebuild diff and the write:
|
||||
|
||||
In `needToRefreshPlayout` (append a clause):
|
||||
```csharp
|
||||
programSchedule.FixedStartTimeBehavior != request.FixedStartTimeBehavior ||
|
||||
programSchedule.PadToNearestMinute != (request.PadToNearestMinute is int upm && upm > 0 ? upm : null);
|
||||
```
|
||||
In the field writes (append):
|
||||
```csharp
|
||||
programSchedule.PadToNearestMinute = request.PadToNearestMinute is int upm2 && upm2 > 0 ? upm2 : null;
|
||||
```
|
||||
(Use distinct local names `upm`/`upm2` to avoid a redeclaration; or hoist a single `int? normalizedPad`
|
||||
local before both uses — implementer's choice, keep it compiling.)
|
||||
|
||||
- [ ] **Step 5: Fix any OTHER positional constructor call sites the compiler flags.** Build and let the
|
||||
compiler find every place that constructs these records positionally (e.g. Auto-Tune / channel-builder code
|
||||
paths, Blazor-era callers if any remain). Add the new trailing arg (`null` where the caller has no pad
|
||||
concept). Run:
|
||||
|
||||
Run: `dotnet build ErsatzTV.sln`
|
||||
Expected: build succeeds after you supply the new arg at each flagged call site.
|
||||
|
||||
- [ ] **Step 6: Run the handler test — expect PASS.**
|
||||
|
||||
Run: `dotnet test ErsatzTV.Application.Tests --filter "FullyQualifiedName~PadToNearestMinute"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Regenerate OpenAPI + TS types.**
|
||||
|
||||
```bash
|
||||
dotnet build ErsatzTV.sln
|
||||
./scripts/update-openapi.sh
|
||||
cd web && npm run generate:api && cd ..
|
||||
```
|
||||
Expected: `v1.json`, `web/src/api/generated/v1.d.ts`, `docs/endpoint-index.md` now show `padToNearestMinute`
|
||||
on `ProgramScheduleResponseModel`, `CreateScheduleRequest`, `UpdateScheduleRequest`.
|
||||
|
||||
- [ ] **Step 8: Verify the API-contract check is clean.**
|
||||
|
||||
Run: `cd web && npm run check:api && cd ..`
|
||||
Expected: no diff (generated types match committed `v1.json`).
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add ErsatzTV.Application ErsatzTV.Core ErsatzTV/Controllers \
|
||||
ErsatzTV/wwwroot/openapi/v1.json web/src/api/generated/v1.d.ts docs/endpoint-index.md
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(392): expose ProgramSchedule.padToNearestMinute on the REST API"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Classic builder — synthetic schedule-level pad + offline advance
|
||||
|
||||
Depends on Task 2. The core behavior. Self-contained synthetic-pad branch in `AddFiller`; reverse-nav
|
||||
population in `PlayoutBuilder`; offline-target advance in the 4 schedulers.
|
||||
|
||||
**Files:**
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutBuilder.cs` (populate `ProgramScheduleItem.ProgramSchedule` reverse nav)
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs` (`AddFiller` return type + synthetic-pad branch)
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerFlood.cs` (line ~115-138)
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs` (line ~203-221)
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerMultiple.cs` (line ~139-150)
|
||||
- Modify: `ErsatzTV.Core/Scheduling/PlayoutModeSchedulerOne.cs` (line ~85-97)
|
||||
- Test: `ErsatzTV.Core.Tests/Scheduling/Goldens/PlayoutBuildGoldenTests.cs`
|
||||
- Golden data: `ErsatzTV.Core.Tests/Scheduling/Goldens/Goldens/classic-schedule-clock-padded-offline.txt`,
|
||||
`...-fallback.txt`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProgramSchedule.PadToNearestMinute` (Task 2); the existing
|
||||
`FallbackFillerForPad(playoutBuilderState, enumerators, scheduleItem, duration, cancellationToken)` →
|
||||
`Option<PlayoutItem>` (unchanged).
|
||||
- Produces: `AddFiller(...)` returns `(List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget)` instead of
|
||||
`List<PlayoutItem>`. `OfflinePadTarget` is non-null ONLY for a synthetic schedule pad that emitted no
|
||||
filler — callers must advance `CurrentTime` to it when it exceeds the content/filler end.
|
||||
|
||||
- [ ] **Step 1: Write failing golden-invariant tests.** In `PlayoutBuildGoldenTests.cs`, add two tests plus
|
||||
their fixtures, modeled on `Classic_clock_padded` / `SeedPaddedData` (which build a `ProgramScheduleItemOne`,
|
||||
a `ProgramSchedule`, a `Playout`, and reference data). The new fixtures set
|
||||
`schedule.PadToNearestMinute = 15` and attach **no** Pad `FillerPreset` to the item.
|
||||
|
||||
Offline variant — no `FallbackFiller`, so gaps are offline (no filler items emitted), and each content item
|
||||
after the first starts on a :15 boundary:
|
||||
```csharp
|
||||
// Issue #392: schedule-level clock padding with NO fallback filler → each content item is padded up to
|
||||
// the next :15 boundary with an OFFLINE gap (no filler items). Proves the synthetic schedule pad advances
|
||||
// the build clock to the boundary even when nothing fills the gap.
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_offline()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: false);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
content.Count.ShouldBeGreaterThan(2);
|
||||
|
||||
// No filler of any kind is emitted (offline gaps only).
|
||||
items.ShouldNotContain(i => i.FillerKind != FillerKind.None);
|
||||
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
await CompareGolden("classic-schedule-clock-padded-offline.txt", items, titles);
|
||||
}
|
||||
```
|
||||
|
||||
Fallback variant — a `FallbackFiller` IS configured, so the gap is filled with `FillerKind.Fallback` content
|
||||
up to the boundary:
|
||||
```csharp
|
||||
// Issue #392: schedule-level clock padding WITH a fallback filler → gaps fill with Fallback content up to
|
||||
// the :15 boundary (no offline gap).
|
||||
[Test]
|
||||
public async Task Classic_schedule_clock_padded_fallback()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildSchedulePaddedPlayout(withFallback: true);
|
||||
|
||||
items.ShouldContain(i => i.FillerKind == FillerKind.Fallback);
|
||||
|
||||
List<PlayoutItem> content = items.Where(i => i.FillerKind == FillerKind.None).OrderBy(i => i.Start).ToList();
|
||||
foreach (PlayoutItem item in content.Skip(1))
|
||||
{
|
||||
(item.Start.Minute % 15).ShouldBe(0, $"content item at {item.Start:HH:mm:ss} is not on a :15 boundary");
|
||||
item.Start.Second.ShouldBe(0);
|
||||
}
|
||||
|
||||
await CompareGolden("classic-schedule-clock-padded-fallback.txt", items, titles);
|
||||
}
|
||||
```
|
||||
|
||||
Add the fixture builder `BuildSchedulePaddedPlayout(bool withFallback)` + `SeedSchedulePaddedData` +
|
||||
`GetSchedulePaddedReferenceData`, copied from `BuildPaddedPlayout`/`SeedPaddedData`/`GetPaddedReferenceData`
|
||||
(lines 727-927) with these changes:
|
||||
- The `ProgramScheduleItemOne` has **no** `PostRollFiller`. When `withFallback`, set `FallbackFiller` to a
|
||||
`FillerPreset { FillerKind = FillerKind.Fallback, FillerMode = FillerMode.None, CollectionType = CollectionType.Collection, Collection = fillerCollection, CollectionId = fillerCollection.Id }` and set the
|
||||
item's `FallbackFillerId`. When not, leave `FallbackFiller` null.
|
||||
- The `ProgramSchedule` sets `PadToNearestMinute = 15` (and `Name = "Schedule Padded Test Schedule"`).
|
||||
- `GetSchedulePaddedReferenceData` mirrors `GetPaddedReferenceData` but the schedule query must
|
||||
`.Include(ps => ps.Items).ThenInclude(psi => psi.FallbackFiller)` (needed for the fallback variant) in
|
||||
addition to `Collection`/`MediaItem`. It does NOT need `PostRollFiller`.
|
||||
|
||||
(Do NOT create the golden `.txt` files yet — Step 4 generates them.)
|
||||
|
||||
- [ ] **Step 2: Run the new tests — expect compile failure / FAIL** (the field and behavior don't exist / the
|
||||
golden files are missing).
|
||||
|
||||
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implement the builder change.**
|
||||
|
||||
**(a) `PlayoutBuilder.cs` — populate the reverse nav once, centrally.** At the top of the `Build(...)` method
|
||||
body (after parameters are in scope, before the day loop), set each schedule item's parent so `AddFiller` can
|
||||
read `scheduleItem.ProgramSchedule.PadToNearestMinute`. Use `referenceData.ProgramSchedule` and each alternate:
|
||||
|
||||
```csharp
|
||||
// #392: the build query does not populate the ProgramScheduleItem.ProgramSchedule reverse nav
|
||||
// (AsNoTracking). Populate it so schedule-level settings (PadToNearestMinute) are readable in AddFiller.
|
||||
if (referenceData.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in referenceData.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = referenceData.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ProgramScheduleAlternate alternate in referenceData.ProgramScheduleAlternates)
|
||||
{
|
||||
if (alternate.ProgramSchedule?.Items is not null)
|
||||
{
|
||||
foreach (ProgramScheduleItem item in alternate.ProgramSchedule.Items)
|
||||
{
|
||||
item.ProgramSchedule = alternate.ProgramSchedule;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Verify the exact accessor names against `PlayoutReferenceData.cs` and `ProgramScheduleAlternate` (the record
|
||||
exposes `ProgramSchedule`). If an accessor differs, adjust — the intent is "every scheduled item points at its
|
||||
own parent schedule."
|
||||
|
||||
**(b) `PlayoutModeSchedulerBase.cs` — extract the pad boundary-math helper, change `AddFiller` return type, add the synthetic-pad branch.**
|
||||
|
||||
First, extract the boundary-math (currently inlined at lines 604-626) into one private static helper that BOTH
|
||||
the existing item-pad path and the new synthetic branch call (DRY; the existing `Classic_clock_padded` golden
|
||||
proves this extraction is behavior-preserving). Add this method to the class:
|
||||
```csharp
|
||||
// #392: shared clock-boundary ceiling used by both the item-level Pad filler and the schedule-level pad.
|
||||
// Returns the next `padToNearestMinute` boundary at or after (blockStart + totalDuration); when the block
|
||||
// already ends exactly on a boundary it advances one full interval (matches the pre-#392 Pad behavior).
|
||||
private static DateTimeOffset ComputePadBoundary(
|
||||
DateTimeOffset blockStart,
|
||||
TimeSpan totalDuration,
|
||||
int padToNearestMinute)
|
||||
{
|
||||
int currentMinute = (blockStart + totalDuration).Minute;
|
||||
int targetMinute = (currentMinute + padToNearestMinute - 1) / padToNearestMinute * padToNearestMinute;
|
||||
|
||||
DateTimeOffset almostTargetTime = blockStart + totalDuration -
|
||||
TimeSpan.FromMinutes(currentMinute) +
|
||||
TimeSpan.FromMinutes(targetMinute);
|
||||
|
||||
var targetTime = new DateTimeOffset(
|
||||
almostTargetTime.Year,
|
||||
almostTargetTime.Month,
|
||||
almostTargetTime.Day,
|
||||
almostTargetTime.Hour,
|
||||
almostTargetTime.Minute,
|
||||
0,
|
||||
almostTargetTime.Offset);
|
||||
|
||||
// ensure filler works for content less than one interval (and for content already on a boundary)
|
||||
if (targetTime <= blockStart + totalDuration)
|
||||
{
|
||||
targetTime = targetTime.AddMinutes(padToNearestMinute);
|
||||
}
|
||||
|
||||
return targetTime;
|
||||
}
|
||||
```
|
||||
Then, in the EXISTING item-pad block, replace the inlined ceiling math (lines 604-626 — the `int currentMinute
|
||||
= ...` through the `if (targetTime <= ...) { targetTime = targetTime.AddMinutes(...); }`) with a single call,
|
||||
leaving the `remainingToFill` line (628) intact:
|
||||
```csharp
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, padFiller.PadToNearestMinute.Value);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
```
|
||||
(This is a pure extraction: the helper body is byte-identical logic to the removed lines. The
|
||||
`Classic_clock_padded` golden in Step 7 must stay green, proving no behavior change.)
|
||||
|
||||
Change the `AddFiller` signature (line 282) return type to a named tuple:
|
||||
```csharp
|
||||
internal (List<PlayoutItem> Items, DateTimeOffset? OfflinePadTarget) AddFiller(
|
||||
PlayoutBuilderState playoutBuilderState,
|
||||
Dictionary<CollectionKey, IMediaCollectionEnumerator> enumerators,
|
||||
ProgramScheduleItem scheduleItem,
|
||||
PlayoutItem playoutItem,
|
||||
List<MediaChapter> chapters,
|
||||
PlayoutBuildWarnings warnings,
|
||||
CancellationToken cancellationToken)
|
||||
```
|
||||
Update the two guard-rail early returns (lines 302, 313) from `return [playoutItem];` to
|
||||
`return ([playoutItem], null);`. Add a local near the top of the method (before the pad section):
|
||||
```csharp
|
||||
DateTimeOffset? offlinePadTarget = null;
|
||||
```
|
||||
Immediately BEFORE the existing item-pad section (the `foreach (FillerPreset padFiller in Optional(
|
||||
allFiller.FirstOrDefault(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue)))` at line 583),
|
||||
insert the self-contained synthetic branch. It engages ONLY when the item has no own Pad filler and the parent
|
||||
schedule has a positive divisor — mutually exclusive with the existing block:
|
||||
```csharp
|
||||
// #392: schedule-level clock padding. Applies only when the item has no own Pad filler preset (that
|
||||
// wins) and the parent schedule declares a positive divisor. Reuses the existing pad boundary math +
|
||||
// FallbackFillerForPad; when no fallback content exists, records an offline target so the caller
|
||||
// advances the build clock to the boundary (leaving an offline gap).
|
||||
bool itemHasPadFiller =
|
||||
allFiller.Any(f => f.FillerMode == FillerMode.Pad && f.PadToNearestMinute.HasValue);
|
||||
if (!itemHasPadFiller &&
|
||||
scheduleItem.ProgramSchedule?.PadToNearestMinute is int schedulePadMinutes &&
|
||||
schedulePadMinutes > 0)
|
||||
{
|
||||
TimeSpan totalDuration = result.Aggregate(
|
||||
TimeSpan.Zero,
|
||||
(acc, i) => acc + (i.FinishOffset - i.StartOffset));
|
||||
|
||||
DateTimeOffset targetTime = ComputePadBoundary(
|
||||
playoutItem.StartOffset, totalDuration, schedulePadMinutes);
|
||||
|
||||
TimeSpan remainingToFill = targetTime - totalDuration - playoutItem.StartOffset;
|
||||
if (remainingToFill > TimeSpan.Zero)
|
||||
{
|
||||
Option<PlayoutItem> maybeFallback = FallbackFillerForPad(
|
||||
playoutBuilderState,
|
||||
enumerators,
|
||||
scheduleItem,
|
||||
remainingToFill,
|
||||
cancellationToken);
|
||||
|
||||
if (maybeFallback.IsSome)
|
||||
{
|
||||
foreach (PlayoutItem fallbackItem in maybeFallback)
|
||||
{
|
||||
result.Add(fallbackItem);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No fallback content: leave an offline gap up to the boundary.
|
||||
offlinePadTarget = targetTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Leave the existing item-pad `foreach` block (583-753) UNCHANGED. Finally, update the method's tail (line 766)
|
||||
from `return result;` to:
|
||||
```csharp
|
||||
return (result, offlinePadTarget);
|
||||
```
|
||||
Confirm the sequential Start/Finish rewrite (lines 755-766) runs unconditionally at the method tail (it does —
|
||||
it precedes `return`), so an appended fallback item is sequenced correctly.
|
||||
|
||||
**(c) Update the 4 scheduler call sites** to destructure the tuple and honor `OfflinePadTarget`.
|
||||
|
||||
`PlayoutModeSchedulerFlood.cs` (~115-138) — replace:
|
||||
```csharp
|
||||
List<PlayoutItem> maybePlayoutItems = AddFiller(
|
||||
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
```
|
||||
with:
|
||||
```csharp
|
||||
(List<PlayoutItem> maybePlayoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
|
||||
|
||||
DateTimeOffset itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);
|
||||
if (clockPadTarget is { } floodPadTarget && floodPadTarget > itemEndTimeWithFiller)
|
||||
{
|
||||
itemEndTimeWithFiller = floodPadTarget;
|
||||
}
|
||||
```
|
||||
(the existing `nextState = nextState with { CurrentTime = itemEndTimeWithFiller, ... }` then picks up the advance).
|
||||
|
||||
`PlayoutModeSchedulerDuration.cs` (~203-221) — same destructure; after computing
|
||||
`itemEndTimeWithFiller = maybePlayoutItems.Max(pi => pi.FinishOffset);` add the same `if (clockPadTarget is { } durPadTarget && durPadTarget > itemEndTimeWithFiller) itemEndTimeWithFiller = durPadTarget;` guard.
|
||||
|
||||
`PlayoutModeSchedulerMultiple.cs` (~139-150) — it currently inlines `.Max` into `CurrentTime`. Refactor:
|
||||
```csharp
|
||||
(List<PlayoutItem> filled, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
nextState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
|
||||
playoutItems.AddRange(filled);
|
||||
|
||||
DateTimeOffset multipleEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
if (clockPadTarget is { } mulPadTarget && mulPadTarget > multipleEnd)
|
||||
{
|
||||
multipleEnd = mulPadTarget;
|
||||
}
|
||||
|
||||
nextState = nextState with
|
||||
{
|
||||
CurrentTime = multipleEnd,
|
||||
```
|
||||
(Adapt to the exact surrounding structure — the key change is that `CurrentTime` becomes
|
||||
`max(items' FinishOffset, clockPadTarget)`. Preserve everything else in the `with` expression.)
|
||||
|
||||
`PlayoutModeSchedulerOne.cs` (~85-97) — same shape:
|
||||
```csharp
|
||||
(List<PlayoutItem> playoutItems, DateTimeOffset? clockPadTarget) = AddFiller(
|
||||
playoutBuilderState, collectionEnumerators, scheduleItem, playoutItem, itemChapters, warnings, cancellationToken);
|
||||
|
||||
DateTimeOffset oneEnd = playoutItems.Max(pi => pi.FinishOffset);
|
||||
if (clockPadTarget is { } onePadTarget && onePadTarget > oneEnd)
|
||||
{
|
||||
oneEnd = onePadTarget;
|
||||
}
|
||||
|
||||
PlayoutBuilderState nextState = playoutBuilderState with
|
||||
{
|
||||
CurrentTime = oneEnd
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Grep for any OTHER `AddFiller(` callers** the four above missed, and update them the same way.
|
||||
|
||||
Run: `grep -rn "AddFiller(" ErsatzTV.Core ErsatzTV.Core.Tests`
|
||||
Expected: only the four schedulers (and possibly a direct test). Update any extra caller to destructure the tuple.
|
||||
|
||||
- [ ] **Step 5: Build.**
|
||||
|
||||
Run: `dotnet build ErsatzTV.Core`
|
||||
Expected: build succeeds.
|
||||
|
||||
- [ ] **Step 6: Generate the two goldens, review, and verify.**
|
||||
|
||||
```bash
|
||||
ETV_UPDATE_PLAYOUT_GOLDENS=1 dotnet test ErsatzTV.Core.Tests \
|
||||
--filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
|
||||
```
|
||||
Expected: writes `classic-schedule-clock-padded-offline.txt` and `...-fallback.txt` (tests report
|
||||
Inconclusive). **Read both golden files** and confirm: offline variant has only `None` content lines with
|
||||
boundary-aligned starts and NO filler lines; fallback variant interleaves `Fallback` filler lines ending on
|
||||
:15 boundaries. Then run WITHOUT the env var:
|
||||
```bash
|
||||
dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_schedule_clock_padded"
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Run the FULL golden suite** to prove no regression to the existing `Classic_clock_padded`
|
||||
(item-level pad path must be byte-identical — the synthetic branch is gated off when an item pad exists).
|
||||
|
||||
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests"`
|
||||
Expected: PASS (all goldens, including `Classic_clock_padded`, unchanged).
|
||||
|
||||
- [ ] **Step 8: Add a precedence test** proving an item's own Pad filler wins when the schedule pad is also set.
|
||||
Add to `PlayoutBuildGoldenTests.cs` a test that builds the SAME fixture as `Classic_clock_padded` but also sets
|
||||
`schedule.PadToNearestMinute = 30` on that schedule, and asserts the output is byte-identical to the existing
|
||||
`classic-clock-padded.txt` golden (the item's :15 PostRoll pad wins; the schedule's :30 is ignored):
|
||||
```csharp
|
||||
// #392: an item's own Pad filler takes precedence over the schedule-level pad (no double-pad).
|
||||
[Test]
|
||||
public async Task Classic_item_pad_wins_over_schedule_pad()
|
||||
{
|
||||
(List<PlayoutItem> items, Dictionary<int, string> titles) = await BuildPaddedPlayout(schedulePadMinutes: 30);
|
||||
await CompareGolden("classic-clock-padded.txt", items, titles); // identical to the item-pad-only golden
|
||||
}
|
||||
```
|
||||
Give `BuildPaddedPlayout`/`SeedPaddedData` an optional `int? schedulePadMinutes = null` parameter that, when
|
||||
set, assigns `schedule.PadToNearestMinute`; the default keeps every existing caller unchanged.
|
||||
|
||||
Run: `dotnet test ErsatzTV.Core.Tests --filter "FullyQualifiedName~PlayoutBuildGoldenTests.Classic_item_pad_wins_over_schedule_pad"`
|
||||
Expected: PASS (reuses the existing golden — no new golden file).
|
||||
|
||||
- [ ] **Step 9: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add ErsatzTV.Core/Scheduling ErsatzTV.Core.Tests/Scheduling/Goldens
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(392): honor ProgramSchedule.PadToNearestMinute in the Classic builder"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: SPA schedule editor control
|
||||
|
||||
Depends on Task 3 (generated TS type). Adds the pad control to the schedule form.
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/schedules/ScheduleForm.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ProgramSchedule.padToNearestMinute` / `CreateScheduleRequest.padToNearestMinute` (Task 3, generated types).
|
||||
- Produces: nothing downstream.
|
||||
|
||||
- [ ] **Step 1: Seed state from the loaded schedule.** In `ScheduleForm.tsx`, next to the existing
|
||||
`randomStart` / `fixedBehavior` state (lines ~25-26), add (value is a string for the `Select`, `''` = none):
|
||||
```tsx
|
||||
const [padToNearest, setPadToNearest] = useState<string>(
|
||||
schedule?.padToNearestMinute != null ? String(schedule.padToNearestMinute) : ''
|
||||
);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Include it in the request body.** In the `body: CreateScheduleRequest = { ... }` object
|
||||
(lines ~41-48), append:
|
||||
```tsx
|
||||
padToNearestMinute: padToNearest === '' ? null : Number(padToNearest)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Render the control.** After the `Fixed start time behavior` `Select` (lines ~96-102), add an
|
||||
object-options `Select` with a `(none)` entry (mirrors `FillerPresetsScreen`'s pad control):
|
||||
```tsx
|
||||
<Select
|
||||
label="Pad to clock boundary"
|
||||
value={padToNearest}
|
||||
onChange={(e) => setPadToNearest(e.target.value)}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
{ label: '5', value: '5' },
|
||||
{ label: '10', value: '10' },
|
||||
{ label: '15', value: '15' },
|
||||
{ label: '30', value: '30' },
|
||||
{ label: '60', value: '60' }
|
||||
]}
|
||||
/>
|
||||
```
|
||||
If the local `Select` component's `options` prop does not accept `{label,value}[]` (the reference used a plain
|
||||
`string[]`), check the `Select` signature in `web/src/components` — `FillerPresetsScreen.tsx` passes
|
||||
`{label,value}[]` to the same component, so this shape is supported; match whichever form the component expects.
|
||||
|
||||
- [ ] **Step 4: Type-check + build.**
|
||||
|
||||
Run: `cd web && npm run build`
|
||||
Expected: build succeeds; `padToNearestMinute` is a known field on the request type.
|
||||
|
||||
- [ ] **Step 5: Lint.**
|
||||
|
||||
Run: `cd web && npm run lint`
|
||||
Expected: no new errors.
|
||||
|
||||
- [ ] **Step 6: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add web/src/schedules/ScheduleForm.tsx
|
||||
git -c core.hooksPath=/dev/null commit -m "feat(392): add pad-to-clock-boundary control to the schedule editor"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Docs — decision record + domain model
|
||||
|
||||
Depends on the behavior existing (Tasks 2-5). Docs-in-same-PR rule.
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/decisions.md` (new active record)
|
||||
- Modify: `docs/domain-model.md` (ProgramSchedule `PadToNearestMinute` field)
|
||||
- Generated: `docs/decisions/README.md` (via `scripts/build_decisions_catalog.py`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes/Produces: none (docs only).
|
||||
|
||||
- [ ] **Step 1: Add the decision record.** Append to `docs/decisions.md` a record following the lifecycle
|
||||
schema (5-field metadata block), extending — not superseding — `sched.clock-padding-existing`:
|
||||
|
||||
```markdown
|
||||
## 2026-07-22 — per-schedule clock-boundary padding is a synthetic content-less Pad over the existing per-episode machinery (#392)
|
||||
|
||||
`key: sched.clock-padding-schedule-toggle`
|
||||
`status: active`
|
||||
`since: 2026-07-22`
|
||||
`supersedes: none`
|
||||
`superseded-by: none`
|
||||
|
||||
A `ProgramSchedule.PadToNearestMinute` (nullable int; null = off) makes the Classic builder pad every content
|
||||
item up to the next N-minute clock boundary WITHOUT a hand-wired Pad `FillerPreset`. It reuses the existing
|
||||
per-content-item Pad path (`PlayoutModeSchedulerBase.AddFiller`, already called once per emitted item): a
|
||||
self-contained synthetic branch engages only when the item has no own `FillerMode.Pad` filler (the item's Pad
|
||||
wins — no double-pad) and the parent schedule declares a positive divisor. The gap fills with the schedule
|
||||
item's `FallbackFiller` via the existing `FallbackFillerForPad`; when no fallback content exists, the branch
|
||||
records an offline target and the four schedulers advance `PlayoutBuilderState.CurrentTime` to the boundary,
|
||||
leaving an implicit offline gap (the same representation fixed-start items use — absence of a `PlayoutItem`,
|
||||
rendered as "Channel is Offline" at stream time). This is the per-schedule convenience layer deferred behind
|
||||
#388 in `sched.clock-padding-existing`; that record's per-item Pad-preset behavior is unchanged. Determinism
|
||||
needs no new anchor/seed state (the pad math is a pure function of offsets). The SPA schedule editor exposes it
|
||||
as a 5/10/15/30/60 minute picker; TZ-independence holds only for divisors of 60. See #77 (prior art) and #392.
|
||||
```
|
||||
(If `sched.clock-padding-existing` should cross-reference this successor, add a one-line "Extended by
|
||||
`sched.clock-padding-schedule-toggle` (#392)." note to its body — a reference, not a status change, since it
|
||||
is not superseded.)
|
||||
|
||||
- [ ] **Step 2: Update the domain model.** In `docs/domain-model.md`, in the `ProgramSchedule` entity/field
|
||||
listing, add a row/line for `PadToNearestMinute` — "optional clock-boundary padding divisor (minutes); when
|
||||
set, the Classic builder pads each content item up to the next boundary, filling with the item's FallbackFiller
|
||||
else offline (#392)."
|
||||
|
||||
- [ ] **Step 3: Regenerate the decisions catalog + validate.**
|
||||
|
||||
```bash
|
||||
python3 scripts/build_decisions_catalog.py
|
||||
python3 scripts/decisions_validate.py
|
||||
```
|
||||
Expected: `docs/decisions/README.md` now lists `sched.clock-padding-schedule-toggle`; the validator passes
|
||||
(reproduce any failure locally — a lone `decisions lifecycle` CI red is a known flake, but a local validator
|
||||
error is real and must be fixed).
|
||||
|
||||
- [ ] **Step 4: Commit.**
|
||||
|
||||
```bash
|
||||
git -c core.hooksPath=/dev/null add docs/decisions.md docs/decisions/README.md docs/domain-model.md
|
||||
git -c core.hooksPath=/dev/null commit -m "docs(392): record per-schedule clock-padding decision + domain-model field"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pre-push gate (run after all tasks, before opening the PR)
|
||||
|
||||
- [ ] Full solution build: `dotnet build ErsatzTV.sln` — succeeds.
|
||||
- [ ] Full test run of touched projects: `dotnet test ErsatzTV.Core.Tests ErsatzTV.Application.Tests` — green.
|
||||
- [ ] Web: `cd web && npm run build && npm run lint && npm run check:api` — green.
|
||||
- [ ] BOM check on touched `.cs`: `for f in $(git diff --name-only origin/main | grep '\.cs$'); do xxd -p "$f" | grep -q '^efbbbf' && echo "BOM: $f"; done` — prints nothing.
|
||||
- [ ] Format gate under bash: `bash -c 'dotnet format whitespace ErsatzTV.sln --folder --verify-no-changes --include $(git diff --name-only origin/main | grep "\.cs$" | tr "\n" " ")'` — clean.
|
||||
- [ ] Live-E2E (`scripts/e2e-local.sh`): create a schedule with `padToNearestMinute` via the API, read it back, build a Classic playout on a channel using it, and confirm (curl the playout detail / XMLTV) that content items land on boundaries. Curl endpoints, never a browser tab; fresh config dir.
|
||||
- [ ] Independent cross-model review of the whole diff (migration + builder + API). Re-review the fix commit if the first review finds issues; loop to a clean `Review-verdict: MERGEABLE @ <head-sha>`.
|
||||
- [ ] Tick the issue's `## Done-when` boxes only against real evidence; post the `Review-verdict` referencing the PR head sha.
|
||||
|
||||
## Self-review notes (coverage check)
|
||||
|
||||
Spec sections → tasks: data model → T2; builder wiring (synthesize + offline branch + precedence) → T4;
|
||||
API → T3; SPA control + 60-min → T5 (+ T1); tests (with-fallback / offline / precedence) → T4; docs
|
||||
(decision record + domain-model) → T6. Fill-source "fallback else offline" → T4 synthetic branch. The one
|
||||
new behavior (advance-to-boundary offline) → T4 (b)/(c). No spec requirement is unassigned.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Design: per-schedule clock-boundary padding toggle (#392)
|
||||
|
||||
**Issue:** [ersatztv#392](http://192.168.1.95:3000/timothy/ersatztv/issues/392) — "Clock-align convenience:
|
||||
one-click per-channel/schedule pad-to-boundary toggle + 60-min increment (SPA)". Follow-up to #77.
|
||||
|
||||
**Status:** approved design, pre-implementation.
|
||||
|
||||
## Problem
|
||||
|
||||
Clock-boundary padding for **Classic** playouts already works end-to-end: a `FillerPreset` with
|
||||
`FillerMode.Pad` + `PadToNearestMinute = N`, attached to a schedule item's Pre/Mid/PostRoll slot, snaps
|
||||
each emitted content item up to the next N-minute clock boundary. `AddFiller`
|
||||
(`PlayoutModeSchedulerBase.cs`) is called **once per emitted content item** inside every scheduler loop
|
||||
(One/Multiple/Flood/Duration), so the padding is already **per-episode**, and it already tops up the gap
|
||||
with the schedule item's `FallbackFiller` via `FallbackFillerForPad` when the pad preset's own content is
|
||||
exhausted. Verified by `PlayoutBuildGoldenTests.Classic_clock_padded` +
|
||||
`ChannelGuideProjectorClockPadTests`. This is the `sched.clock-padding-existing` decision (#77/#388).
|
||||
|
||||
The **only** gap is convenience: to clock-align a whole schedule today, a user must create a Pad
|
||||
`FillerPreset` and hand-wire it into *every* schedule item's filler slot. #392 collapses that to a single
|
||||
schedule-level setting. #388 (the design-system epic this UI work was gated behind) has since closed, so
|
||||
the toggle is unblocked.
|
||||
|
||||
## Approved decisions (from brainstorming)
|
||||
|
||||
1. **Home = per-schedule**, not per-channel. Lives on `ProgramSchedule` beside its existing behavior flags
|
||||
(`ShuffleScheduleItems`, `RandomStartPoint`, `FixedStartTimeBehavior`). Per-channel was rejected as
|
||||
awkward plumbing (the channel drives a schedule; the builder would have to reach through the playout).
|
||||
2. **Fill source = the schedule item's `FallbackFiller` if configured, else offline** (dead-air gap).
|
||||
Reuses the existing `FallbackFillerForPad` top-up path; no content-selection surface on the toggle.
|
||||
3. **Granularity = per-episode** — every content item padded to a boundary (real-TV-listing look). This is
|
||||
what the existing per-item Pad path already does; no new per-item loop is needed.
|
||||
4. **Precedence:** when a schedule item has its **own** hand-wired Pad filler preset, the **item's Pad
|
||||
wins** and the schedule-level pad is skipped for that item. This respects explicit per-item config and
|
||||
avoids `AddFiller`'s existing "more than one Pad filler" guard-rail error.
|
||||
|
||||
## The one genuinely-new behavior
|
||||
|
||||
Today, when a Pad path computes a gap but has **no** content and **no** fallback filler,
|
||||
`FallbackFillerForPad` returns `None` and inserts nothing — so `nextState.CurrentTime` advances only to the
|
||||
content item's end and the next item starts **immediately** (no offline gap, no boundary alignment). To
|
||||
honor decision #2's "else offline", the schedule-level pad must, when nothing fills the gap, **advance the
|
||||
build clock to the boundary target time**, leaving a true offline gap (an absence of a `PlayoutItem`, which
|
||||
the streaming layer renders as "Channel is Offline"). This is the only new builder behavior; it is
|
||||
localized to the pad-fill branch and only engages for the schedule-level synthetic pad, never changing the
|
||||
existing per-item Pad-preset behavior.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Data model
|
||||
- Add `int? PadToNearestMinute` to `ErsatzTV.Core/Domain/ProgramSchedule.cs` (null / absent = feature off).
|
||||
- EF config unchanged structurally (nullable int column); dual-provider migration via
|
||||
`scripts/add-migration.sh <Name>` (Sqlite **and** MySql).
|
||||
|
||||
### 2. Builder wiring (`ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs`, `AddFiller`)
|
||||
- At pad-preset selection (~L583-585): if the item has no Pre/Mid/Post filler with `FillerMode.Pad`
|
||||
**and** the parent `ProgramSchedule.PadToNearestMinute` is set, synthesize a content-less Pad behavior
|
||||
with that divisor (treat as PostRoll-style, matching the `Classic_clock_padded` fixture).
|
||||
- The synthetic pad runs the existing boundary math. With no pad collection it skips `AddDurationFiller`
|
||||
and goes straight to `FallbackFillerForPad` → schedule item's `FallbackFiller` if present, else the new
|
||||
offline branch (advance `CurrentTime` to `targetTime`).
|
||||
- Precedence guard: presence of any item-level `FillerMode.Pad` filler suppresses the synthetic pad
|
||||
(decision #4). Never construct two pad fillers.
|
||||
- **Access to the parent schedule flag:** confirm `AddFiller` can reach the `ProgramSchedule`
|
||||
(via `scheduleItem.ProgramSchedule` nav or a builder-passed value). If the nav is not loaded on this
|
||||
path, thread the `int?` divisor down from `PlayoutBuilder` rather than force-loading a nav — decided at
|
||||
implementation time, but the value flows one-way and read-only.
|
||||
- Determinism: the pad boundary math is a pure function of `playoutItem.StartOffset` + already-added filler
|
||||
durations (no wall-clock "now" dependency), and `FallbackFillerForPad` advances its own enumerator, so no
|
||||
new `PlayoutAnchor`/`Seed` state is required (same reason #77 needed none).
|
||||
|
||||
### 3. API
|
||||
- Expose `padToNearestMinute` (nullable int) on the ProgramSchedule response DTO and the create/update
|
||||
request DTO. Confirm the exact DTO/handler names during planning (schedule GET/PUT surface).
|
||||
- Regenerate OpenAPI artifacts: build the app project, `./scripts/update-openapi.sh`, `npm run
|
||||
generate:api` (per `process.pr-routine-sequence`). Ships `v1.json`, `v1.d.ts`, `endpoint-index.md` in the
|
||||
same diff (blocking `api-docs` gate).
|
||||
|
||||
### 4. SPA
|
||||
- Add a "Pad to clock boundary" control to the schedule editor screen: a minute `Select` with `(none)` = off
|
||||
and options `5 / 10 / 15 / 30 / 60`, bound to `padToNearestMinute`. Follow `spa-conventions.md`.
|
||||
- Add `60` to `PAD_OPTIONS` in `web/src/screens/FillerPresetsScreen.tsx` (currently `[5, 10, 15, 30]`; the
|
||||
backend already accepts any integer).
|
||||
|
||||
### 5. Tests
|
||||
- New golden test alongside `PlayoutBuildGoldenTests.Classic_clock_padded`, driven by a schedule with
|
||||
`PadToNearestMinute` set and **no** per-item Pad preset. Two cases:
|
||||
- **with `FallbackFiller`** → gap filled with fallback content, block ends on the boundary;
|
||||
- **without `FallbackFiller`** → offline gap, next item starts on the boundary.
|
||||
- A test asserting **precedence**: an item with its own Pad preset + schedule-level pad on → behaves exactly
|
||||
as the item's Pad preset (schedule pad suppressed, no double-pad error).
|
||||
- Regenerate goldens only locally with `ETV_UPDATE_PLAYOUT_GOLDENS` — **never** set it in CI.
|
||||
- TZ-independence holds only when the divisor divides 60; keep the existing golden's TZ-invariance note.
|
||||
|
||||
### 6. Docs
|
||||
- New decision record extending (not reversing) `sched.clock-padding-existing`: the per-schedule convenience
|
||||
layer over the existing per-item Pad machinery + the new offline-advance semantics. Add the record;
|
||||
cross-reference the predecessor; no archive move (not a supersession).
|
||||
- Update `docs/domain-model.md` (ProgramSchedule field) and `docs/spa-conventions.md` if the schedule-editor
|
||||
control introduces a new pattern; `docs/api-conventions.md` checklist for the DTO change.
|
||||
|
||||
## Non-goals / YAGNI
|
||||
- Per-channel toggle (rejected — per-schedule chosen).
|
||||
- Tunarr's pad-per-slot-vs-episode as a *configurable* distinction — per-episode is the single behavior.
|
||||
- A content-selection surface on the toggle (fills with fallback-else-offline only).
|
||||
- Any change to Sequential/YAML (`pad_to_next`/`pad_until` already cover those engines) or Block.
|
||||
|
||||
## Risk & review
|
||||
Touches a DB migration + Classic builder logic + an API write-path DTO → **independent review is
|
||||
mandatory** (`process.independent-review-rubric`). Live-E2E on the schedule editor round-trip
|
||||
(`release.live-e2e-required`) before push.
|
||||
|
||||
## Done-when (mirrors the issue)
|
||||
- [ ] adversarial review passed
|
||||
- [ ] backend option + API + Classic builder wiring, with tests
|
||||
- [ ] SPA control added + 60-min increment option
|
||||
- [ ] docs updated (domain-model / decisions / spa-conventions / api-conventions as applicable)
|
||||
Vendored
+3
@@ -548,6 +548,7 @@ export interface components {
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
"padToNearestMinute": null | number;
|
||||
};
|
||||
"CreateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
@@ -1231,6 +1232,7 @@ export interface components {
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
"padToNearestMinute": null | number;
|
||||
};
|
||||
"RemoteConnectionResponseModel": {
|
||||
"address": string;
|
||||
@@ -1807,6 +1809,7 @@ export interface components {
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
"padToNearestMinute": null | number;
|
||||
};
|
||||
"UpdateSmartCollectionRequest": {
|
||||
"name": null | string;
|
||||
|
||||
@@ -24,6 +24,9 @@ export function ScheduleForm({ mode, schedule, onSaved, onClose }: ScheduleFormP
|
||||
const [shuffle, setShuffle] = useState(schedule?.shuffleScheduleItems ?? false);
|
||||
const [randomStart, setRandomStart] = useState(schedule?.randomStartPoint ?? false);
|
||||
const [fixedBehavior, setFixedBehavior] = useState<'Strict' | 'Flexible'>(schedule?.fixedStartTimeBehavior ?? 'Strict');
|
||||
const [padToNearest, setPadToNearest] = useState<string>(
|
||||
schedule?.padToNearestMinute != null ? String(schedule.padToNearestMinute) : ''
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -44,7 +47,8 @@ export function ScheduleForm({ mode, schedule, onSaved, onClose }: ScheduleFormP
|
||||
treatCollectionsAsShows: keepMultiPart ? treatAsShows : false,
|
||||
shuffleScheduleItems: shuffle,
|
||||
randomStartPoint: randomStart,
|
||||
fixedStartTimeBehavior: fixedBehavior
|
||||
fixedStartTimeBehavior: fixedBehavior,
|
||||
padToNearestMinute: padToNearest === '' ? null : Number(padToNearest)
|
||||
};
|
||||
|
||||
setSaving(true);
|
||||
@@ -100,6 +104,19 @@ export function ScheduleForm({ mode, schedule, onSaved, onClose }: ScheduleFormP
|
||||
onChange={(e) => setFixedBehavior(e.target.value as 'Strict' | 'Flexible')}
|
||||
options={['Strict', 'Flexible']}
|
||||
/>
|
||||
<Select
|
||||
label="Pad to clock boundary"
|
||||
value={padToNearest}
|
||||
onChange={(e) => setPadToNearest(e.target.value)}
|
||||
options={[
|
||||
{ label: '(none)', value: '' },
|
||||
{ label: '5', value: '5' },
|
||||
{ label: '10', value: '10' },
|
||||
{ label: '15', value: '15' },
|
||||
{ label: '30', value: '30' },
|
||||
{ label: '60', value: '60' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ const MODE_OPTIONS = [
|
||||
{ label: 'Random Count', value: 'RandomCount' }
|
||||
];
|
||||
|
||||
const PAD_OPTIONS = [5, 10, 15, 30].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
|
||||
const PAD_OPTIONS = [5, 10, 15, 30, 60].map((minutes) => ({ label: String(minutes), value: String(minutes) }));
|
||||
|
||||
// CollectionType -> the library-browse media type + which request id field it fills.
|
||||
const COLLECTION_TYPES: Array<{
|
||||
|
||||
Reference in New Issue
Block a user