Browse Source

add classic schedule marathon (#2400)

pull/2401/head
Jason Dove 11 months ago committed by GitHub
parent
commit
0d69dd58a4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      CHANGELOG.md
  2. 45
      ErsatzTV.Application/ProgramSchedules/Commands/AddProgramScheduleItem.cs
  3. 4
      ErsatzTV.Application/ProgramSchedules/Commands/IProgramScheduleItemRequest.cs
  4. 16
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  5. 4
      ErsatzTV.Application/ProgramSchedules/Commands/ReplaceProgramScheduleItems.cs
  6. 16
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  7. 8
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs
  8. 8
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs
  9. 8
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs
  10. 8
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs
  11. 4
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs
  12. 6
      ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
  13. 10
      ErsatzTV.Core/Domain/MarathonGroupBy.cs
  14. 3
      ErsatzTV.Core/Domain/PlaybackOrder.cs
  15. 4
      ErsatzTV.Core/Domain/ProgramScheduleItem.cs
  16. 59
      ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs
  17. 1
      ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs
  18. 3
      ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs
  19. 22
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  20. 83
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  21. 3
      ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs
  22. 6393
      ErsatzTV.Infrastructure.MySql/Migrations/20250909135040_Add_ScheduleItemMarathon.Designer.cs
  23. 50
      ErsatzTV.Infrastructure.MySql/Migrations/20250909135040_Add_ScheduleItemMarathon.cs
  24. 6396
      ErsatzTV.Infrastructure.MySql/Migrations/20250909144439_Add_ScheduleItemMarathonShuffleItems.Designer.cs
  25. 29
      ErsatzTV.Infrastructure.MySql/Migrations/20250909144439_Add_ScheduleItemMarathonShuffleItems.cs
  26. 12
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  27. 6228
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250909135007_Add_ScheduleItemMarathon.Designer.cs
  28. 50
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250909135007_Add_ScheduleItemMarathon.cs
  29. 6231
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250909144730_Add_ScheduleItemMarathonShuffleItems.Designer.cs
  30. 29
      ErsatzTV.Infrastructure.Sqlite/Migrations/20250909144730_Add_ScheduleItemMarathonShuffleItems.cs
  31. 12
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  32. 2
      ErsatzTV/Pages/Artist.razor
  33. 51
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  34. 2
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  35. 2
      ErsatzTV/Pages/TelevisionSeasonList.razor
  36. 8
      ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

10
CHANGELOG.md

@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -13,6 +13,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `Playout Offset` can be used to offset the times of scheduled playout items from the mirror source channel
- e.g. -2 hours will cause the mirror channel to play content 2 hours before the mirror source channel
- Add support for `.aif`, `.aifc`, `.aiff` song files
- Classic schedules: add playback order `Marathon`
- This can be used with collections and smart collections
- Items from the collection will be grouped by the `Marathon Group By` setting: `Artist`, `Album`, `Season` or `Show`
- The order of groups can optionally be shuffled
- The order of items in each group can optionally be shuffled (otherwise `Season, Episode` or `Chronological` as appropriate)
- A batch size can be set to limit the number of items to schedule from each group at a time
- Empty or zero batch size means play all items from each group before advancing
- Any other value means play the specified number of items before advancing to the next group
### Fixed
- Fix transcoding content with bt709/pc color metadata
@ -2116,7 +2124,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -2116,7 +2124,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Allow `Shuffle In Order` with Collections and Smart Collections
- Episodes will be grouped by show, and music videos will be grouped by artist
- All movies will be a single group (multi-collections are probably better if `Shuffle In Order` is desired for movies)
- All groups will be be ordered chronologically (custom ordering is only supported in multi-collections)
- All groups will be ordered chronologically (custom ordering is only supported in multi-collections)
### Fixed
- Generate XMLTV that validates successfully

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

@ -17,6 +17,10 @@ public record AddProgramScheduleItem( @@ -17,6 +17,10 @@ public record AddProgramScheduleItem(
int? MediaItemId,
int? PlaylistId,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,
@ -36,4 +40,43 @@ public record AddProgramScheduleItem( @@ -36,4 +40,43 @@ public record AddProgramScheduleItem(
string PreferredAudioTitle,
string PreferredSubtitleLanguageCode,
ChannelSubtitleMode? SubtitleMode) : IRequest<Either<BaseError, ProgramScheduleItemViewModel>>,
IProgramScheduleItemRequest;
IProgramScheduleItemRequest
{
public static AddProgramScheduleItem ForMediaItem(int scheduleId, ProgramScheduleItemCollectionType collectionType, int mediaItemId)
=> new(
scheduleId,
StartType.Dynamic,
StartTime: null,
FixedStartTimeBehavior: null,
PlayoutMode.One,
collectionType,
CollectionId: null,
MultiCollectionId: null,
SmartCollectionId: null,
MediaItemId: mediaItemId,
PlaylistId: null,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
MarathonShuffleGroups: false,
MarathonShuffleItems: false,
MarathonBatchSize: null,
FillWithGroupMode.None,
MultipleMode.Count,
MultipleCount: null,
PlayoutDuration: null,
TailMode.None,
DiscardToFillAttempts: null,
CustomTitle: null,
GuideMode.Normal,
PreRollFillerId: null,
MidRollFillerId: null,
PostRollFillerId: null,
TailFillerId: null,
FallbackFillerId: null,
WatermarkIds: [],
GraphicsElementIds: [],
PreferredAudioLanguageCode: null,
PreferredAudioTitle: null,
PreferredSubtitleLanguageCode: null,
SubtitleMode: null);
}

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

@ -15,6 +15,10 @@ public interface IProgramScheduleItemRequest @@ -15,6 +15,10 @@ public interface IProgramScheduleItemRequest
int? PlaylistId { get; }
PlayoutMode PlayoutMode { get; }
PlaybackOrder PlaybackOrder { get; }
MarathonGroupBy MarathonGroupBy { get; }
bool MarathonShuffleGroups { get; }
bool MarathonShuffleItems { get; }
int? MarathonBatchSize { get; }
FillWithGroupMode FillWithGroupMode { get; }
MultipleMode MultipleMode { get; }
int? MultipleCount { get; }

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

@ -202,6 +202,10 @@ public abstract class ProgramScheduleItemCommandBase @@ -202,6 +202,10 @@ public abstract class ProgramScheduleItemCommandBase
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
@ -228,6 +232,10 @@ public abstract class ProgramScheduleItemCommandBase @@ -228,6 +232,10 @@ public abstract class ProgramScheduleItemCommandBase
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = FillWithGroupMode.None,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
@ -254,6 +262,10 @@ public abstract class ProgramScheduleItemCommandBase @@ -254,6 +262,10 @@ public abstract class ProgramScheduleItemCommandBase
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
MultipleMode = item.MultipleMode,
Count = item.MultipleMode is MultipleMode.Count ? item.MultipleCount.GetValueOrDefault() : 0,
@ -282,6 +294,10 @@ public abstract class ProgramScheduleItemCommandBase @@ -282,6 +294,10 @@ public abstract class ProgramScheduleItemCommandBase
MediaItemId = item.MediaItemId,
PlaylistId = item.PlaylistId,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
TailMode = item.TailMode,

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

@ -17,6 +17,10 @@ public record ReplaceProgramScheduleItem( @@ -17,6 +17,10 @@ public record ReplaceProgramScheduleItem(
int? MediaItemId,
int? PlaylistId,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
MultipleMode MultipleMode,
int? MultipleCount,

16
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -45,6 +45,10 @@ internal static class Mapper @@ -45,6 +45,10 @@ internal static class Mapper
_ => null
},
duration.PlaybackOrder,
duration.MarathonGroupBy,
duration.MarathonShuffleGroups,
duration.MarathonShuffleItems,
duration.MarathonBatchSize,
duration.FillWithGroupMode,
duration.PlayoutDuration,
duration.TailMode,
@ -102,6 +106,10 @@ internal static class Mapper @@ -102,6 +106,10 @@ internal static class Mapper
_ => null
},
flood.PlaybackOrder,
flood.MarathonGroupBy,
flood.MarathonShuffleGroups,
flood.MarathonShuffleItems,
flood.MarathonBatchSize,
flood.FillWithGroupMode,
flood.CustomTitle,
flood.GuideMode,
@ -156,6 +164,10 @@ internal static class Mapper @@ -156,6 +164,10 @@ internal static class Mapper
_ => null
},
multiple.PlaybackOrder,
multiple.MarathonGroupBy,
multiple.MarathonShuffleGroups,
multiple.MarathonShuffleItems,
multiple.MarathonBatchSize,
multiple.FillWithGroupMode,
multiple.MultipleMode,
multiple.Count,
@ -212,6 +224,10 @@ internal static class Mapper @@ -212,6 +224,10 @@ internal static class Mapper
_ => null
},
one.PlaybackOrder,
one.MarathonGroupBy,
one.MarathonShuffleGroups,
one.MarathonShuffleItems,
one.MarathonBatchSize,
one.FillWithGroupMode,
one.CustomTitle,
one.GuideMode,

8
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemDurationViewModel.cs

@ -23,6 +23,10 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode @@ -23,6 +23,10 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
PlaylistViewModel playlist,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
TimeSpan playoutDuration,
TailMode tailMode,
@ -53,6 +57,10 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode @@ -53,6 +57,10 @@ public record ProgramScheduleItemDurationViewModel : ProgramScheduleItemViewMode
playlist,
mediaItem,
playbackOrder,
marathonGroupBy,
marathonShuffleGroups,
marathonShuffleItems,
marathonBatchSize,
fillWithGroupMode,
customTitle,
guideMode,

8
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemFloodViewModel.cs

@ -23,6 +23,10 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel @@ -23,6 +23,10 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
PlaylistViewModel playlist,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
string customTitle,
GuideMode guideMode,
@ -50,6 +54,10 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel @@ -50,6 +54,10 @@ public record ProgramScheduleItemFloodViewModel : ProgramScheduleItemViewModel
playlist,
mediaItem,
playbackOrder,
marathonGroupBy,
marathonShuffleGroups,
marathonShuffleItems,
marathonBatchSize,
fillWithGroupMode,
customTitle,
guideMode,

8
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemMultipleViewModel.cs

@ -23,6 +23,10 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode @@ -23,6 +23,10 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
PlaylistViewModel playlist,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
MultipleMode multipleMode,
int count,
@ -52,6 +56,10 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode @@ -52,6 +56,10 @@ public record ProgramScheduleItemMultipleViewModel : ProgramScheduleItemViewMode
playlist,
mediaItem,
playbackOrder,
marathonGroupBy,
marathonShuffleGroups,
marathonShuffleItems,
marathonBatchSize,
fillWithGroupMode,
customTitle,
guideMode,

8
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemOneViewModel.cs

@ -23,6 +23,10 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel @@ -23,6 +23,10 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
PlaylistViewModel playlist,
NamedMediaItemViewModel mediaItem,
PlaybackOrder playbackOrder,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
int? marathonBatchSize,
FillWithGroupMode fillWithGroupMode,
string customTitle,
GuideMode guideMode,
@ -50,6 +54,10 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel @@ -50,6 +54,10 @@ public record ProgramScheduleItemOneViewModel : ProgramScheduleItemViewModel
playlist,
mediaItem,
playbackOrder,
marathonGroupBy,
marathonShuffleGroups,
marathonShuffleItems,
marathonBatchSize,
fillWithGroupMode,
customTitle,
guideMode,

4
ErsatzTV.Application/ProgramSchedules/ProgramScheduleItemViewModel.cs

@ -22,6 +22,10 @@ public abstract record ProgramScheduleItemViewModel( @@ -22,6 +22,10 @@ public abstract record ProgramScheduleItemViewModel(
PlaylistViewModel Playlist,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
MarathonGroupBy MarathonGroupBy,
bool MarathonShuffleGroups,
bool MarathonShuffleItems,
int? MarathonBatchSize,
FillWithGroupMode FillWithGroupMode,
string CustomTitle,
GuideMode GuideMode,

6
ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs

@ -60,7 +60,8 @@ public class PlaylistEnumeratorTests @@ -60,7 +60,8 @@ public class PlaylistEnumeratorTests
repo,
playlistItemMap,
new CollectionEnumeratorState(),
false,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();
@ -127,7 +128,8 @@ public class PlaylistEnumeratorTests @@ -127,7 +128,8 @@ public class PlaylistEnumeratorTests
repo,
playlistItemMap,
new CollectionEnumeratorState(),
false,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();

10
ErsatzTV.Core/Domain/MarathonGroupBy.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
namespace ErsatzTV.Core.Domain;
public enum MarathonGroupBy
{
None = 0,
Show = 1,
Season = 2,
Artist = 3,
Album = 4
}

3
ErsatzTV.Core/Domain/PlaybackOrder.cs

@ -10,5 +10,6 @@ public enum PlaybackOrder @@ -10,5 +10,6 @@ public enum PlaybackOrder
ShuffleInOrder = 4,
MultiEpisodeShuffle = 5,
SeasonEpisode = 6,
RandomRotation = 7
RandomRotation = 7,
Marathon = 8
}

4
ErsatzTV.Core/Domain/ProgramScheduleItem.cs

@ -31,6 +31,10 @@ public abstract class ProgramScheduleItem @@ -31,6 +31,10 @@ public abstract class ProgramScheduleItem
public Playlist Playlist { get; set; }
public string FakeCollectionKey { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public MarathonGroupBy MarathonGroupBy { get; set; }
public bool MarathonShuffleGroups { get; set; }
public bool MarathonShuffleItems { get; set; }
public int? MarathonBatchSize { get; set; }
public int? PreRollFillerId { get; set; }
public FillerPreset PreRollFiller { get; set; }
public int? MidRollFillerId { get; set; }

59
ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs

@ -7,6 +7,64 @@ namespace ErsatzTV.Core.Scheduling.Engine; @@ -7,6 +7,64 @@ namespace ErsatzTV.Core.Scheduling.Engine;
public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository)
{
public async Task<Option<PlaylistEnumerator>> GetEnumerator(
List<MediaItem> mediaItems,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
Option<int> marathonBatchSize,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
{
List<IGrouping<GroupKey, MediaItem>> groups = [];
PlaybackOrder itemPlaybackOrder;
// group by show
switch (marathonGroupBy)
{
case MarathonGroupBy.Show:
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByShow));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.SeasonEpisode;
break;
case MarathonGroupBy.Season:
groups.AddRange(mediaItems.GroupBy(MediaItemKeyBySeason));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.SeasonEpisode;
break;
case MarathonGroupBy.Artist:
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByArtist));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
break;
case MarathonGroupBy.Album:
groups.AddRange(mediaItems.GroupBy(MediaItemKeyByAlbum));
itemPlaybackOrder = marathonShuffleItems ? PlaybackOrder.Shuffle : PlaybackOrder.Chronological;
break;
default:
return Option<PlaylistEnumerator>.None;
}
Dictionary<PlaylistItem, List<MediaItem>> itemMap = [];
for (var index = 0; index < groups.Count; index++)
{
IGrouping<GroupKey, MediaItem> group = groups[index];
PlaylistItem playlistItem = GroupToPlaylistItem(
index,
marathonBatchSize.IsNone,
itemPlaybackOrder,
group);
itemMap.Add(playlistItem, group.ToList());
}
return await PlaylistEnumerator.Create(
mediaCollectionRepository,
itemMap,
state,
marathonShuffleGroups,
marathonBatchSize,
cancellationToken);
}
public async Task<Option<PlaylistContentResult>> GetEnumerator(
Dictionary<string, List<string>> guids,
List<string> searches,
@ -68,6 +126,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -68,6 +126,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
itemMap,
state,
shuffleGroups,
batchSize: Option<int>.None,
cancellationToken);
return new PlaylistContentResult(

1
ErsatzTV.Core/Scheduling/Engine/PlaylistHelper.cs

@ -57,6 +57,7 @@ public class PlaylistHelper(IMediaCollectionRepository mediaCollectionRepository @@ -57,6 +57,7 @@ public class PlaylistHelper(IMediaCollectionRepository mediaCollectionRepository
itemMap,
state,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
return new PlaylistContentResult(

3
ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs

@ -255,7 +255,8 @@ public class SchedulingEngine( @@ -255,7 +255,8 @@ public class SchedulingEngine(
mediaCollectionRepository,
itemMap,
state,
false,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
CancellationToken.None);
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);

22
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -15,6 +15,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -15,6 +15,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
private CloneableRandom _random;
private bool _shufflePlaylistItems;
private List<IMediaCollectionEnumerator> _sortedEnumerators;
private int _itemsTakenFromCurrent;
private Option<int> _batchSize = Option<int>.None;
private PlaylistEnumerator()
{
@ -65,11 +67,18 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -65,11 +67,18 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
}
_sortedEnumerators[EnumeratorIndex].MoveNext();
_itemsTakenFromCurrent++;
// if we aren't playing all, or if we just finished playing all, move to the next enumerator
if (!_playAll[EnumeratorIndex] || _sortedEnumerators[EnumeratorIndex].State.Index == 0)
bool shouldSwitchEnumerator = _batchSize.Match(
// move to the next enumerator if we've hit the batch size
batchSize => _itemsTakenFromCurrent >= batchSize,
// if we aren't playing all, or if we just finished playing all, move to the next enumerator
() => !_playAll[EnumeratorIndex] || _sortedEnumerators[EnumeratorIndex].State.Index == 0);
if (shouldSwitchEnumerator)
{
EnumeratorIndex = (EnumeratorIndex + 1) % _sortedEnumerators.Count;
_itemsTakenFromCurrent = 0;
}
State.Index += 1;
@ -94,6 +103,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -94,6 +103,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
Dictionary<PlaylistItem, List<MediaItem>> playlistItemMap,
CollectionEnumeratorState state,
bool shufflePlaylistItems,
Option<int> batchSize,
CancellationToken cancellationToken)
{
var result = new PlaylistEnumerator
@ -101,7 +111,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -101,7 +111,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
_sortedEnumerators = [],
_playAll = [],
_idsToIncludeInEPG = [],
_shufflePlaylistItems = shufflePlaylistItems
_shufflePlaylistItems = shufflePlaylistItems,
_batchSize = batchSize
};
// collections should share enumerators
@ -167,6 +178,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -167,6 +178,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
case PlaybackOrder.SeasonEpisode:
// TODO: check random start point?
enumerator = new SeasonEpisodeMediaCollectionEnumerator(items, initState);
// season, episode will filter out season 0, so we may get an empty enumerator back
if (enumerator.Count == 0)
{
enumerator = null;
}
break;
case PlaybackOrder.Random:
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);

83
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -5,11 +5,11 @@ using ErsatzTV.Core.Extensions; @@ -5,11 +5,11 @@ using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling.Engine;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Newtonsoft.Json;
using Map = LanguageExt.Map;
using Humanizer;
namespace ErsatzTV.Core.Scheduling;
@ -530,18 +530,44 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -530,18 +530,44 @@ public class PlayoutBuilder : IPlayoutBuilder
// use configured playback order for primary collection, shuffle for filler
Option<ProgramScheduleItem> maybeScheduleItem = sortedScheduleItems
.FirstOrDefault(item => CollectionKey.ForScheduleItem(item) == collectionKey);
PlaybackOrder playbackOrder = maybeScheduleItem
.Match(item => item.PlaybackOrder, () => PlaybackOrder.Shuffle);
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(
playout,
activeSchedule,
collectionKey,
mediaItems,
playbackOrder,
randomStartPoint,
cancellationToken);
collectionEnumerators.Add(collectionKey, enumerator);
foreach (var scheduleItem in maybeScheduleItem)
{
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(
playout,
activeSchedule,
collectionKey,
mediaItems,
scheduleItem.PlaybackOrder,
scheduleItem.MarathonGroupBy,
scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize,
randomStartPoint,
cancellationToken);
collectionEnumerators.Add(collectionKey, enumerator);
}
// filler
if (maybeScheduleItem.IsNone)
{
IMediaCollectionEnumerator enumerator =
await GetMediaCollectionEnumerator(
playout,
activeSchedule,
collectionKey,
mediaItems,
PlaybackOrder.Shuffle,
MarathonGroupBy.None,
marathonShuffleGroups: false,
marathonShuffleItems: false,
marathonBatchSize: null,
randomStartPoint,
cancellationToken);
collectionEnumerators.Add(collectionKey, enumerator);
}
}
var collectionItemCount = collectionMediaItems.Map((k, v) => (k, v.Count)).Values.ToDictionary();
@ -618,6 +644,10 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -618,6 +644,10 @@ public class PlayoutBuilder : IPlayoutBuilder
key,
fakeCollection.MediaItems,
scheduleItem.PlaybackOrder,
scheduleItem.MarathonGroupBy,
scheduleItem.MarathonShuffleGroups,
scheduleItem.MarathonShuffleItems,
scheduleItem.MarathonBatchSize,
randomStartPoint,
cancellationToken);
@ -1162,6 +1192,10 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1162,6 +1192,10 @@ public class PlayoutBuilder : IPlayoutBuilder
CollectionKey collectionKey,
List<MediaItem> mediaItems,
PlaybackOrder playbackOrder,
MarathonGroupBy marathonGroupBy,
bool marathonShuffleGroups,
bool marathonShuffleItems,
int? marathonBatchSize,
bool randomStartPoint,
CancellationToken cancellationToken)
{
@ -1199,7 +1233,8 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1199,7 +1233,8 @@ public class PlayoutBuilder : IPlayoutBuilder
_mediaCollectionRepository,
playlistItemMap,
state,
false,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
}
}
@ -1304,6 +1339,26 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1304,6 +1339,26 @@ public class PlayoutBuilder : IPlayoutBuilder
cancellationToken),
state,
cancellationToken);
case PlaybackOrder.Marathon:
var helper = new MarathonHelper(_mediaCollectionRepository);
Option<PlaylistEnumerator> maybeEnumerator = await helper.GetEnumerator(
mediaItems,
marathonGroupBy,
marathonShuffleGroups,
marathonShuffleItems,
marathonBatchSize is > 0 ? marathonBatchSize.Value : Option<int>.None,
state,
cancellationToken);
foreach (var enumerator in maybeEnumerator)
{
return enumerator;
}
// fall through to default case if we can't make the proper enumerator
goto default;
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);

3
ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs

@ -173,7 +173,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor @@ -173,7 +173,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
mediaCollectionRepository,
itemMap,
state,
false,
shufflePlaylistItems: false,
batchSize: Option<int>.None,
cancellationToken);
}

6393
ErsatzTV.Infrastructure.MySql/Migrations/20250909135040_Add_ScheduleItemMarathon.Designer.cs generated

File diff suppressed because it is too large Load Diff

50
ErsatzTV.Infrastructure.MySql/Migrations/20250909135040_Add_ScheduleItemMarathon.cs

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_ScheduleItemMarathon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MarathonBatchSize",
table: "ProgramScheduleItem",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MarathonGroupBy",
table: "ProgramScheduleItem",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "MarathonShuffleGroups",
table: "ProgramScheduleItem",
type: "tinyint(1)",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MarathonBatchSize",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "MarathonGroupBy",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "MarathonShuffleGroups",
table: "ProgramScheduleItem");
}
}
}

6396
ErsatzTV.Infrastructure.MySql/Migrations/20250909144439_Add_ScheduleItemMarathonShuffleItems.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.MySql/Migrations/20250909144439_Add_ScheduleItemMarathonShuffleItems.cs

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

12
ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs

@ -2221,6 +2221,18 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -2221,6 +2221,18 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("Index")
.HasColumnType("int");
b.Property<int?>("MarathonBatchSize")
.HasColumnType("int");
b.Property<int>("MarathonGroupBy")
.HasColumnType("int");
b.Property<bool>("MarathonShuffleGroups")
.HasColumnType("tinyint(1)");
b.Property<bool>("MarathonShuffleItems")
.HasColumnType("tinyint(1)");
b.Property<int?>("MediaItemId")
.HasColumnType("int");

6228
ErsatzTV.Infrastructure.Sqlite/Migrations/20250909135007_Add_ScheduleItemMarathon.Designer.cs generated

File diff suppressed because it is too large Load Diff

50
ErsatzTV.Infrastructure.Sqlite/Migrations/20250909135007_Add_ScheduleItemMarathon.cs

@ -0,0 +1,50 @@ @@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_ScheduleItemMarathon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "MarathonBatchSize",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "MarathonGroupBy",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "MarathonShuffleGroups",
table: "ProgramScheduleItem",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MarathonBatchSize",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "MarathonGroupBy",
table: "ProgramScheduleItem");
migrationBuilder.DropColumn(
name: "MarathonShuffleGroups",
table: "ProgramScheduleItem");
}
}
}

6231
ErsatzTV.Infrastructure.Sqlite/Migrations/20250909144730_Add_ScheduleItemMarathonShuffleItems.Designer.cs generated

File diff suppressed because it is too large Load Diff

29
ErsatzTV.Infrastructure.Sqlite/Migrations/20250909144730_Add_ScheduleItemMarathonShuffleItems.cs

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

12
ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs

@ -2114,6 +2114,18 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2114,6 +2114,18 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int?>("MarathonBatchSize")
.HasColumnType("INTEGER");
b.Property<int>("MarathonGroupBy")
.HasColumnType("INTEGER");
b.Property<bool>("MarathonShuffleGroups")
.HasColumnType("INTEGER");
b.Property<bool>("MarathonShuffleItems")
.HasColumnType("INTEGER");
b.Property<int?>("MediaItemId")
.HasColumnType("INTEGER");

2
ErsatzTV/Pages/Artist.razor

@ -310,7 +310,7 @@ @@ -310,7 +310,7 @@
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.Artist, null, null, null, ArtistId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, MultipleMode.Count, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, [], null, null, null, null), _cts.Token);
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, ProgramScheduleItemCollectionType.Artist, ArtistId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

51
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -355,6 +355,7 @@ @@ -355,6 +355,7 @@
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Marathon">Marathon</MudSelectItem>
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
@ -373,6 +374,44 @@ @@ -373,6 +374,44 @@
}
</MudSelect>
</MudStack>
@if (_selectedItem.PlaybackOrder is PlaybackOrder.Marathon)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Marathon Group By</MudText>
</div>
<MudSelect @bind-Value="@_selectedItem.MarathonGroupBy" For="@(() => _selectedItem.MarathonGroupBy)">
<MudSelectItem Value="MarathonGroupBy.Album">Album</MudSelectItem>
<MudSelectItem Value="MarathonGroupBy.Artist">Artist</MudSelectItem>
<MudSelectItem Value="MarathonGroupBy.Season">Season</MudSelectItem>
<MudSelectItem Value="MarathonGroupBy.Show">Show</MudSelectItem>
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Marathon Shuffle Groups</MudText>
</div>
<MudCheckBox @bind-Value="_selectedItem.MarathonShuffleGroups"
For="@(() => _selectedItem.MarathonShuffleGroups)"
Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Marathon Shuffle Items</MudText>
</div>
<MudCheckBox @bind-Value="_selectedItem.MarathonShuffleItems"
For="@(() => _selectedItem.MarathonShuffleItems)"
Dense="true"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Marathon Batch Size</MudText>
</div>
<MudTextField @bind-Value="@_selectedItem.MarathonBatchSize"
For="@(() => _selectedItem.MarathonBatchSize)"
HelperText="How many items to play from each group before advancing; empty or zero will play all items"/>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Mode</MudText>
@ -802,6 +841,10 @@ @@ -802,6 +841,10 @@
Playlist = item.Playlist,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
@ -873,6 +916,10 @@ @@ -873,6 +916,10 @@
Playlist = item.Playlist,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
MarathonGroupBy = item.MarathonGroupBy,
MarathonShuffleGroups = item.MarathonShuffleGroups,
MarathonShuffleItems = item.MarathonShuffleItems,
MarathonBatchSize = item.MarathonBatchSize,
FillWithGroupMode = item.FillWithGroupMode,
CustomTitle = item.CustomTitle,
GuideMode = item.GuideMode,
@ -943,6 +990,10 @@ @@ -943,6 +990,10 @@
item.MediaItem?.MediaItemId,
item.Playlist?.Id,
item.PlaybackOrder,
item.MarathonGroupBy,
item.MarathonShuffleGroups,
item.MarathonShuffleItems,
item.MarathonBatchSize,
item.FillWithGroupMode,
item.MultipleMode,
item.MultipleCount,

2
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -267,7 +267,7 @@ @@ -267,7 +267,7 @@
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionSeason, null, null, null, SeasonId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, MultipleMode.Count, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, [], null, null, null, null), _cts.Token);
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, ProgramScheduleItemCollectionType.TelevisionSeason, SeasonId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

2
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -266,7 +266,7 @@ @@ -266,7 +266,7 @@
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(new AddProgramScheduleItem(schedule.Id, StartType.Dynamic, null, null, PlayoutMode.One, ProgramScheduleItemCollectionType.TelevisionShow, null, null, null, ShowId, null, PlaybackOrder.Shuffle, FillWithGroupMode.None, MultipleMode.Count, null, null, TailMode.None, null, null, GuideMode.Normal, null, null, null, null, null, null, [], null, null, null, null), _cts.Token);
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, ProgramScheduleItemCollectionType.TelevisionShow, ShowId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}

8
ErsatzTV/ViewModels/ProgramScheduleItemEditViewModel.cs

@ -135,6 +135,14 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged @@ -135,6 +135,14 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
}
}
public MarathonGroupBy MarathonGroupBy { get; set; }
public bool MarathonShuffleGroups { get; set; }
public bool MarathonShuffleItems { get; set; }
public int? MarathonBatchSize { get; set; }
public MultipleMode MultipleMode { get; set; }
public int? MultipleCount

Loading…
Cancel
Save