Browse Source

add duration discard to fill attempts (#1301)

pull/1302/head
Jason Dove 3 years ago committed by GitHub
parent
commit
5da2bdbab4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs
  3. 1
      ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs
  4. 6
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  5. 1
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs
  6. 2
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs
  7. 1
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  8. 3
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs
  9. 1
      ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs
  10. 11
      ErsatzTV.Core/Extensions/MediaItemExtensions.cs
  11. 1
      ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs
  12. 6
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  13. 6
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  14. 152
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs
  15. 5
      ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs
  16. 6
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  17. 5
      ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs
  18. 5
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  19. 4419
      ErsatzTV.Infrastructure/Migrations/20230613201543_Add_ProgramScheduleItemDuration_DiscardToFillAttempts.Designer.cs
  20. 29
      ErsatzTV.Infrastructure/Migrations/20230613201543_Add_ProgramScheduleItemDuration_DiscardToFillAttempts.cs
  21. 5
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  22. 5
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs
  23. 2
      ErsatzTV/Pages/Artist.razor
  24. 3
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  25. 2
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  26. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  27. 6
      ErsatzTV/Validators/ProgramScheduleItemEditViewModelValidator.cs
  28. 8
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

4
CHANGELOG.md

@ -7,6 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Added ### Added
- Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted) - Disable playout buttons and show spinning indicator when a playout is being modified (built/extended, or subtitles are being extracted)
- Automatically reload playout details table when playout build is complete - Automatically reload playout details table when playout build is complete
- Add `Discard To Fill Attempts` setting to duration playout mode
- This setting only has an effect when it's configured to be greater than zero
- When the current item is longer than the remaining duration, it will be discarded and ETV will try to fit the next item in the collection, up to the configured number of times
- When the remaining duration is shorter than all items in the collection, the normal filler logic will be used
### Fixed ### Fixed
- Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item - Skip checking for subtitles to extract when subtitles are not enabled on a channel/schedule item

1
ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs

@ -17,6 +17,7 @@ public record AddProgramScheduleItem(
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
TailMode TailMode, TailMode TailMode,
int? DiscardToFillAttempts,
string CustomTitle, string CustomTitle,
GuideMode GuideMode, GuideMode GuideMode,
int? PreRollFillerId, int? PreRollFillerId,

1
ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs

@ -15,6 +15,7 @@ public interface IProgramScheduleItemRequest
int? MultipleCount { get; } int? MultipleCount { get; }
TimeSpan? PlayoutDuration { get; } TimeSpan? PlayoutDuration { get; }
TailMode TailMode { get; } TailMode TailMode { get; }
int? DiscardToFillAttempts { get; }
string CustomTitle { get; } string CustomTitle { get; }
GuideMode GuideMode { get; } GuideMode GuideMode { get; }
int? PreRollFillerId { get; } int? PreRollFillerId { get; }

6
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -83,6 +83,11 @@ public abstract class ProgramScheduleItemCommandBase
return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'"); return BaseError.New("[PlayoutDuration] is required for playout mode 'duration'");
} }
if (item.DiscardToFillAttempts is null)
{
return BaseError.New("[DiscardToFillAttempts] is required for playout mode 'duration'");
}
if (item.TailMode == TailMode.Filler && item.TailFillerId == null) if (item.TailMode == TailMode.Filler && item.TailFillerId == null)
{ {
return BaseError.New("Tail Filler is required with tail mode Filler"); return BaseError.New("Tail Filler is required with tail mode Filler");
@ -248,6 +253,7 @@ public abstract class ProgramScheduleItemCommandBase
PlaybackOrder = item.PlaybackOrder, PlaybackOrder = item.PlaybackOrder,
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()), PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
TailMode = item.TailMode, TailMode = item.TailMode,
DiscardToFillAttempts = item.DiscardToFillAttempts.GetValueOrDefault(),
CustomTitle = item.CustomTitle, CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode, GuideMode = item.GuideMode,
PreRollFillerId = item.PreRollFillerId, PreRollFillerId = item.PreRollFillerId,

1
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs

@ -17,6 +17,7 @@ public record ReplaceProgramScheduleItem(
int? MultipleCount, int? MultipleCount,
TimeSpan? PlayoutDuration, TimeSpan? PlayoutDuration,
TailMode TailMode, TailMode TailMode,
int? DiscardToFillAttempts,
string CustomTitle, string CustomTitle,
GuideMode GuideMode, GuideMode GuideMode,
int? PreRollFillerId, int? PreRollFillerId,

2
ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItemsHandler.cs

@ -29,7 +29,7 @@ public class ReplaceProgramScheduleItemsHandler : ProgramScheduleItemCommandBase
{ {
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request); Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request);
return await LanguageExtensions.Apply(validation, ps => PersistItems(dbContext, request, ps)); return await validation.Apply(ps => PersistItems(dbContext, request, ps));
} }
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems( private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(

1
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -42,6 +42,7 @@ internal static class Mapper
duration.PlaybackOrder, duration.PlaybackOrder,
duration.PlayoutDuration, duration.PlayoutDuration,
duration.TailMode, duration.TailMode,
duration.DiscardToFillAttempts,
duration.CustomTitle, duration.CustomTitle,
duration.GuideMode, duration.GuideMode,
duration.PreRollFiller != null duration.PreRollFiller != null

3
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

@ -21,6 +21,7 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
PlaybackOrder playbackOrder, PlaybackOrder playbackOrder,
TimeSpan playoutDuration, TimeSpan playoutDuration,
TailMode tailMode, TailMode tailMode,
int discardToFillAttempts,
string customTitle, string customTitle,
GuideMode guideMode, GuideMode guideMode,
FillerPresetViewModel preRollFiller, FillerPresetViewModel preRollFiller,
@ -59,8 +60,10 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
{ {
PlayoutDuration = playoutDuration; PlayoutDuration = playoutDuration;
TailMode = tailMode; TailMode = tailMode;
DiscardToFillAttempts = discardToFillAttempts;
} }
public TimeSpan PlayoutDuration { get; } public TimeSpan PlayoutDuration { get; }
public TailMode TailMode { get; } public TailMode TailMode { get; }
public int DiscardToFillAttempts { get; }
} }

1
ErsatzTV.Core/Domain/ProgramScheduleItemDuration.cs

@ -4,4 +4,5 @@ public class ProgramScheduleItemDuration : ProgramScheduleItem
{ {
public TimeSpan PlayoutDuration { get; set; } public TimeSpan PlayoutDuration { get; set; }
public TailMode TailMode { get; set; } public TailMode TailMode { get; set; }
public int DiscardToFillAttempts { get; set; }
} }

11
ErsatzTV.Core/Extensions/MediaItemExtensions.cs

@ -7,6 +7,17 @@ namespace ErsatzTV.Core.Extensions;
public static class MediaItemExtensions public static class MediaItemExtensions
{ {
public static Option<TimeSpan> GetDuration(this MediaItem mediaItem) =>
mediaItem switch
{
Movie m => m.MediaVersions.HeadOrNone().Map(v => v.Duration),
Episode e => e.MediaVersions.HeadOrNone().Map(v => v.Duration),
MusicVideo mv => mv.MediaVersions.HeadOrNone().Map(v => v.Duration),
OtherVideo ov => ov.MediaVersions.HeadOrNone().Map(v => v.Duration),
Song s => s.MediaVersions.HeadOrNone().Map(v => v.Duration),
_ => None
};
public static MediaVersion GetHeadVersion(this MediaItem mediaItem) => public static MediaVersion GetHeadVersion(this MediaItem mediaItem) =>
mediaItem switch mediaItem switch
{ {

1
ErsatzTV.Core/Interfaces/Scheduling/IMediaCollectionEnumerator.cs

@ -8,4 +8,5 @@ public interface IMediaCollectionEnumerator
Option<MediaItem> Current { get; } Option<MediaItem> Current { get; }
void MoveNext(); void MoveNext();
Option<MediaItem> Peek(int offset); Option<MediaItem> Peek(int offset);
Option<TimeSpan> MinimumDuration { get; }
} }

6
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -6,12 +7,15 @@ namespace ErsatzTV.Core.Scheduling;
public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public ChronologicalMediaCollectionEnumerator( public ChronologicalMediaCollectionEnumerator(
IEnumerable<MediaItem> mediaItems, IEnumerable<MediaItem> mediaItems,
CollectionEnumeratorState state) CollectionEnumeratorState state)
{ {
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList(); _sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
@ -35,4 +39,6 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
public Option<MediaItem> Peek(int offset) => public Option<MediaItem> Peek(int offset) =>
_sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None; _sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

6
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -6,6 +7,7 @@ namespace ErsatzTV.Core.Scheduling;
public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public CustomOrderCollectionEnumerator( public CustomOrderCollectionEnumerator(
Collection collection, Collection collection,
@ -17,6 +19,8 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
.OrderBy(ci => ci.CustomIndex) .OrderBy(ci => ci.CustomIndex)
.Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId)) .Map(ci => mediaItems.First(mi => mi.Id == ci.MediaItemId))
.ToList(); .ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index) while (State.Index < state.Index)
@ -33,4 +37,6 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public Option<MediaItem> Peek(int offset) => public Option<MediaItem> Peek(int offset) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

152
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerDuration.cs

@ -26,6 +26,7 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
var willFinishInTime = true; var willFinishInTime = true;
Option<DateTimeOffset> durationUntil = None; Option<DateTimeOffset> durationUntil = None;
int discardAttempts = 0;
IMediaCollectionEnumerator contentEnumerator = IMediaCollectionEnumerator contentEnumerator =
collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)]; collectionEnumerators[CollectionKey.ForScheduleItem(scheduleItem)];
@ -68,77 +69,108 @@ public class PlayoutModeSchedulerDuration : PlayoutModeSchedulerBase<ProgramSche
continue; continue;
} }
var playoutItem = new PlayoutItem TimeSpan remainingDuration = durationUntil.ValueUnsafe() - itemStartTime;
if (scheduleItem.DiscardToFillAttempts > 0 &&
remainingDuration >= contentEnumerator.MinimumDuration.IfNone(TimeSpan.Zero) &&
itemDuration > remainingDuration)
{ {
MediaItemId = mediaItem.Id, discardAttempts++;
Start = itemStartTime.UtcDateTime, if (discardAttempts > scheduleItem.DiscardToFillAttempts)
Finish = itemStartTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
GuideGroup = nextState.NextGuideGroup,
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
};
durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators,
scheduleItem,
itemStartTime,
itemDuration,
itemChapters);
willFinishInTime = itemStartTime > durationFinish ||
itemEndTimeWithFiller <= durationFinish;
if (willFinishInTime)
{
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(
AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
playoutItem,
itemChapters,
cancellationToken));
nextState = nextState with
{ {
CurrentTime = itemEndTimeWithFiller, nextState = nextState with
{
DurationFinish = None
};
// only bump guide group if we don't have a custom title nextState.ScheduleItemsEnumerator.MoveNext();
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle) }
? nextState.IncrementGuideGroup else
: nextState.NextGuideGroup {
}; _logger.LogDebug(
"Skipping playout item {Title} with duration {Duration} that is longer than remaining duration {RemainingDuration}",
PlayoutBuilder.DisplayTitle(mediaItem),
itemDuration,
remainingDuration);
contentEnumerator.MoveNext(); contentEnumerator.MoveNext();
}
} }
else else
{ {
TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime; discardAttempts = 0;
if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration)
{
_logger.LogWarning(
"Unable to schedule duration block of {DurationBlock} which is longer than the configured playout duration {PlayoutDuration}",
durationBlock,
scheduleItem.PlayoutDuration);
}
nextState = nextState with var playoutItem = new PlayoutItem
{ {
DurationFinish = None MediaItemId = mediaItem.Id,
Start = itemStartTime.UtcDateTime,
Finish = itemStartTime.UtcDateTime + itemDuration,
InPoint = TimeSpan.Zero,
OutPoint = itemDuration,
GuideGroup = nextState.NextGuideGroup,
FillerKind = scheduleItem.GuideMode == GuideMode.Filler
? FillerKind.GuideMode
: FillerKind.None,
CustomTitle = scheduleItem.CustomTitle,
WatermarkId = scheduleItem.WatermarkId,
PreferredAudioLanguageCode = scheduleItem.PreferredAudioLanguageCode,
PreferredAudioTitle = scheduleItem.PreferredAudioTitle,
PreferredSubtitleLanguageCode = scheduleItem.PreferredSubtitleLanguageCode,
SubtitleMode = scheduleItem.SubtitleMode
}; };
nextState.ScheduleItemsEnumerator.MoveNext(); durationUntil.Do(du => playoutItem.GuideFinish = du.UtcDateTime);
DateTimeOffset durationFinish = nextState.DurationFinish.IfNone(SystemTime.MaxValueUtc);
DateTimeOffset itemEndTimeWithFiller = CalculateEndTimeWithFiller(
collectionEnumerators,
scheduleItem,
itemStartTime,
itemDuration,
itemChapters);
willFinishInTime = itemStartTime > durationFinish ||
itemEndTimeWithFiller <= durationFinish;
if (willFinishInTime)
{
// LogScheduledItem(scheduleItem, mediaItem, itemStartTime);
playoutItems.AddRange(
AddFiller(
nextState,
collectionEnumerators,
scheduleItem,
playoutItem,
itemChapters,
cancellationToken));
nextState = nextState with
{
CurrentTime = itemEndTimeWithFiller,
// only bump guide group if we don't have a custom title
NextGuideGroup = string.IsNullOrWhiteSpace(scheduleItem.CustomTitle)
? nextState.IncrementGuideGroup
: nextState.NextGuideGroup
};
contentEnumerator.MoveNext();
}
else
{
TimeSpan durationBlock = itemEndTimeWithFiller - itemStartTime;
if (itemEndTimeWithFiller - itemStartTime > scheduleItem.PlayoutDuration)
{
_logger.LogWarning(
"Unable to schedule duration block of {DurationBlock} which is longer than the configured playout duration {PlayoutDuration}",
durationBlock,
scheduleItem.PlayoutDuration);
}
nextState = nextState with
{
DurationFinish = None
};
nextState.ScheduleItemsEnumerator.MoveNext();
}
} }
} }

5
ErsatzTV.Core/Scheduling/RandomizedMediaCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -6,12 +7,14 @@ namespace ErsatzTV.Core.Scheduling;
public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _mediaItems; private readonly IList<MediaItem> _mediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly Random _random; private readonly Random _random;
private int _index; private int _index;
public RandomizedMediaCollectionEnumerator(IList<MediaItem> mediaItems, CollectionEnumeratorState state) public RandomizedMediaCollectionEnumerator(IList<MediaItem> mediaItems, CollectionEnumeratorState state)
{ {
_mediaItems = mediaItems; _mediaItems = mediaItems;
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _mediaItems.Bind(i => i.GetDuration()).HeadOrNone());
_random = new Random(state.Seed); _random = new Random(state.Seed);
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
@ -35,4 +38,6 @@ public class RandomizedMediaCollectionEnumerator : IMediaCollectionEnumerator
public Option<MediaItem> Peek(int offset) => public Option<MediaItem> Peek(int offset) =>
throw new NotSupportedException(); throw new NotSupportedException();
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

6
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -6,12 +7,15 @@ namespace ErsatzTV.Core.Scheduling;
public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator
{ {
private readonly IList<MediaItem> _sortedMediaItems; private readonly IList<MediaItem> _sortedMediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public SeasonEpisodeMediaCollectionEnumerator( public SeasonEpisodeMediaCollectionEnumerator(
IEnumerable<MediaItem> mediaItems, IEnumerable<MediaItem> mediaItems,
CollectionEnumeratorState state) CollectionEnumeratorState state)
{ {
_sortedMediaItems = mediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList(); _sortedMediaItems = mediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList();
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(
() => _sortedMediaItems.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
@ -35,4 +39,6 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
public Option<MediaItem> Peek(int offset) => public Option<MediaItem> Peek(int offset) =>
_sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None; _sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None;
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

5
ErsatzTV.Core/Scheduling/ShuffleInOrderCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -11,6 +12,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
private readonly bool _randomStartPoint; private readonly bool _randomStartPoint;
private Random _random; private Random _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public ShuffleInOrderCollectionEnumerator( public ShuffleInOrderCollectionEnumerator(
IList<CollectionWithItems> collections, IList<CollectionWithItems> collections,
@ -31,6 +33,7 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
_random = new Random(state.Seed); _random = new Random(state.Seed);
_shuffled = Shuffle(_collections, _random); _shuffled = Shuffle(_collections, _random);
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index) while (State.Index < state.Index)
@ -203,4 +206,6 @@ public class ShuffleInOrderCollectionEnumerator : IMediaCollectionEnumerator
public int Index { get; set; } public int Index { get; set; }
public IList<Option<MediaItem>> Items { get; set; } public IList<Option<MediaItem>> Items { get; set; }
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

5
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling; namespace ErsatzTV.Core.Scheduling;
@ -8,6 +9,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
private readonly CancellationToken _cancellationToken; private readonly CancellationToken _cancellationToken;
private readonly int _mediaItemCount; private readonly int _mediaItemCount;
private readonly IList<GroupedMediaItem> _mediaItems; private readonly IList<GroupedMediaItem> _mediaItems;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private CloneableRandom _random; private CloneableRandom _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
@ -28,6 +30,7 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
_random = new CloneableRandom(state.Seed); _random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_mediaItems, _random); _shuffled = Shuffle(_mediaItems, _random);
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index) while (State.Index < state.Index)
@ -105,4 +108,6 @@ public class ShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount); return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

4419
ErsatzTV.Infrastructure/Migrations/20230613201543_Add_ProgramScheduleItemDuration_DiscardToFillAttempts.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure/Migrations/20230613201543_Add_ProgramScheduleItemDuration_DiscardToFillAttempts.cs

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class Add_ProgramScheduleItemDuration_DiscardToFillAttempts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "DiscardToFillAttempts",
table: "ProgramScheduleDurationItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DiscardToFillAttempts",
table: "ProgramScheduleDurationItem");
}
}
}

5
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.4"); modelBuilder.HasAnnotation("ProductVersion", "7.0.7");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b => modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{ {
@ -2503,6 +2503,9 @@ namespace ErsatzTV.Infrastructure.Migrations
{ {
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem"); b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("DiscardToFillAttempts")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration") b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT"); .HasColumnType("TEXT");

5
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling; using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Scripting; using ErsatzTV.Core.Interfaces.Scripting;
using ErsatzTV.Core.Scheduling; using ErsatzTV.Core.Scheduling;
@ -15,6 +16,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
private readonly List<MediaItem> _ungrouped; private readonly List<MediaItem> _ungrouped;
private CloneableRandom _random; private CloneableRandom _random;
private IList<MediaItem> _shuffled; private IList<MediaItem> _shuffled;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
public MultiEpisodeShuffleCollectionEnumerator( public MultiEpisodeShuffleCollectionEnumerator(
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
@ -72,6 +74,7 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
_random = new CloneableRandom(state.Seed); _random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_random); _shuffled = Shuffle(_random);
_lazyMinimumDuration = new Lazy<Option<TimeSpan>>(() => _shuffled.Bind(i => i.GetDuration()).HeadOrNone());
State = new CollectionEnumeratorState { Seed = state.Seed }; State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index) while (State.Index < state.Index)
@ -197,4 +200,6 @@ public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerato
return copy; return copy;
} }
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
} }

2
ErsatzTV/Pages/Artist.razor

@ -258,7 +258,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule) if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
} }
} }

3
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -228,6 +228,7 @@
<MudSelectItem Value="@TailMode.Offline">Offline</MudSelectItem> <MudSelectItem Value="@TailMode.Offline">Offline</MudSelectItem>
<MudSelectItem Value="@TailMode.Filler">Filler</MudSelectItem> <MudSelectItem Value="@TailMode.Filler">Filler</MudSelectItem>
</MudSelect> </MudSelect>
<MudTextField Class="mt-3" Label="Discard To Fill Attempts" @bind-Value="@_selectedItem.DiscardToFillAttempts" For="@(() => _selectedItem.DiscardToFillAttempts)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/> <MudTextField Class="mt-3" Label="Custom Title" @bind-Value="@_selectedItem.CustomTitle" For="@(() => _selectedItem.CustomTitle)"/>
<MudSelect Class="mt-3" Label="Guide Mode" @bind-Value="@_selectedItem.GuideMode" For="@(() => _selectedItem.GuideMode)"> <MudSelect Class="mt-3" Label="Guide Mode" @bind-Value="@_selectedItem.GuideMode" For="@(() => _selectedItem.GuideMode)">
<MudSelectItem Value="@GuideMode.Normal">Normal</MudSelectItem> <MudSelectItem Value="@GuideMode.Normal">Normal</MudSelectItem>
@ -489,6 +490,7 @@
case ProgramScheduleItemDurationViewModel duration: case ProgramScheduleItemDurationViewModel duration:
result.PlayoutDuration = duration.PlayoutDuration; result.PlayoutDuration = duration.PlayoutDuration;
result.TailMode = duration.TailMode; result.TailMode = duration.TailMode;
result.DiscardToFillAttempts = duration.DiscardToFillAttempts;
break; break;
} }
@ -546,6 +548,7 @@
item.MultipleCount, item.MultipleCount,
item.PlayoutDuration, item.PlayoutDuration,
item.TailMode, item.TailMode,
item.DiscardToFillAttempts,
item.CustomTitle, item.CustomTitle,
item.GuideMode, item.GuideMode,
item.PreRollFiller?.Id, item.PreRollFiller?.Id,

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -241,7 +241,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule) if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
} }
} }

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -228,7 +228,7 @@
DialogResult result = await dialog.Result; DialogResult result = await dialog.Result;
if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule) if (!result.Canceled && result.Data is ProgramScheduleViewModel schedule)
{ {
await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token); await _mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, PlaybackOrder.Shuffle, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, null, null, null, null), _cts.Token);
_navigationManager.NavigateTo($"schedules/{schedule.Id}/items"); _navigationManager.NavigateTo($"schedules/{schedule.Id}/items");
} }
} }

6
ErsatzTV/Validators/ProgramScheduleItemEditViewModelValidator.cs

@ -16,6 +16,10 @@ public class ProgramScheduleItemEditViewModelValidator : AbstractValidator<Progr
() => RuleFor(i => i.MultipleCount).NotNull().GreaterThanOrEqualTo(0)); () => RuleFor(i => i.MultipleCount).NotNull().GreaterThanOrEqualTo(0));
When( When(
i => i.PlayoutMode == PlayoutMode.Duration, i => i.PlayoutMode == PlayoutMode.Duration,
() => RuleFor(i => i.PlayoutDuration).NotNull()); () =>
{
RuleFor(i => i.PlayoutDuration).NotNull();
RuleFor(i => i.DiscardToFillAttempts).NotNull().GreaterThanOrEqualTo(0);
});
} }
} }

8
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -13,6 +13,7 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
{ {
private ProgramScheduleItemCollectionType _collectionType; private ProgramScheduleItemCollectionType _collectionType;
private int? _multipleCount; private int? _multipleCount;
private int? _discardToFillAttempts;
private TimeSpan? _playoutDuration; private TimeSpan? _playoutDuration;
private TimeSpan? _startTime; private TimeSpan? _startTime;
@ -96,7 +97,12 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
} }
public TailMode TailMode { get; set; } public TailMode TailMode { get; set; }
public int? DiscardToFillAttempts
{
get => PlayoutMode == PlayoutMode.Duration ? _discardToFillAttempts ?? 0 : null;
set => _discardToFillAttempts = value;
}
public string CustomTitle { get; set; } public string CustomTitle { get; set; }
public GuideMode GuideMode { get; set; } public GuideMode GuideMode { get; set; }

Loading…
Cancel
Save