Browse Source

fix: multiple playlist and marathon fixes (#2996)

pull/2997/head
Jason Dove 2 weeks ago committed by GitHub
parent
commit
b0f313eec8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      CHANGELOG.md
  2. 2
      ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMarathon.cs
  3. 129
      ErsatzTV.Core.Tests/Scheduling/PlaylistEnumeratorTests.cs
  4. 150
      ErsatzTV.Core.Tests/Scheduling/PlaylistHistoryTests.cs
  5. 4
      ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs
  6. 18
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs
  7. 2
      ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutFillerBuilder.cs
  8. 6
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  9. 6
      ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs
  10. 12
      ErsatzTV.Core/Scheduling/Engine/MarathonHelper.cs
  11. 29
      ErsatzTV.Core/Scheduling/Engine/SchedulingEngine.cs
  12. 33
      ErsatzTV.Core/Scheduling/HistoryDetails.cs
  13. 110
      ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs
  14. 37
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  15. 17
      ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs
  16. 6
      ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs
  17. 9
      ErsatzTV.Core/Scheduling/YamlScheduling/EnumeratorCache.cs
  18. 18
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs
  19. 2
      ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs
  20. 7066
      ErsatzTV.Infrastructure.MySql/Migrations/20260831123102_Add_PlayoutHistory_Seed.Designer.cs
  21. 28
      ErsatzTV.Infrastructure.MySql/Migrations/20260831123102_Add_PlayoutHistory_Seed.cs
  22. 3
      ErsatzTV.Infrastructure.MySql/Migrations/TvContextModelSnapshot.cs
  23. 6893
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260831123022_Add_PlayoutHistory_Seed.Designer.cs
  24. 28
      ErsatzTV.Infrastructure.Sqlite/Migrations/20260831123022_Add_PlayoutHistory_Seed.cs
  25. 3
      ErsatzTV.Infrastructure.Sqlite/Migrations/TvContextModelSnapshot.cs
  26. 6
      ErsatzTV/Controllers/Api/ScriptedScheduleController.cs

6
CHANGELOG.md

@ -11,10 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -11,10 +11,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Properly detect QSV capabilities for Intel 10th gen and older devices; previously they always used software transcoding
- Fix block scheduler deleting the current hour's playout items, taking the channel offline until the next block
- Fix playlists and marathons not continuing from the saved position after a restart or a playout rebuild
- Affects playlists and marathons in sequential and scripted schedules, and playlists used as block deco filler
- Episodes that had already played would repeat, and others would be skipped each time the collection came up
- Only the last collection of a playlist kept its position (regression from `v26.7.0`)
- Sequential and scripted schedules first build after upgrading may still start at the wrong item; every build after that will be correct
- Fix shuffled playlists and marathons (`shuffle_groups`) playing the wrong content after the first full cycle
- Fix multiple causes of playout build hangs
- Fix **Skip Missing Items** being ignored by multi-collection shuffle, shuffle in order, and playlists
- Sequential schedules:
- Fix `shuffle_sequence` losing the shuffled order at the end of each build; previously the next build continued in schedule file order
- Fix `shuffle_sequence` deleting the instructions between two uses of the same sequence

2
ErsatzTV.Core.Nullable/Api/ScriptedPlayout/ContentMarathon.cs

@ -8,7 +8,7 @@ public record ContentMarathon @@ -8,7 +8,7 @@ public record ContentMarathon
public required string Key { get; set; }
[Description(
"Tells the scheduler how to group the combined content (returned from all guids and searches). Valid values are show, season, artist and album.")]
"Tells the scheduler how to group the combined content (returned from all guids and searches). Valid values are show, season, artist, album and director.")]
public required string GroupBy { get; set; }
[Description("Playback order within each group; only chronological and shuffle are currently supported")]

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

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using NSubstitute;
using NUnit.Framework;
@ -524,6 +525,134 @@ public class PlaylistEnumeratorTests @@ -524,6 +525,134 @@ public class PlaylistEnumeratorTests
continued.ShouldBe(expected);
}
// callers use EnumeratorIndex on ChildEnumerators, and each cycle end changes the shuffled order
[Test]
public async Task ChildEnumerators_Should_Follow_The_Shuffle()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
Dictionary<PlaylistItem, List<MediaItem>> BuildMap() => new()
{
{ PlaylistItemFor(1), [FakeMovie(1), FakeMovie(2)] },
{ PlaylistItemFor(2), [FakeMovie(3), FakeMovie(4)] },
{ PlaylistItemFor(3), [FakeMovie(5), FakeMovie(6)] },
{ PlaylistItemFor(4), [FakeMovie(7), FakeMovie(8)] }
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
BuildMap(),
new CollectionEnumeratorState { Seed = 12345, Index = 0 },
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
var sawSecondCycle = false;
for (var i = 0; i < 40; i++)
{
IMediaCollectionEnumerator currentChild = enumerator.ChildEnumerators[enumerator.EnumeratorIndex]
.Enumerator;
currentChild.Current.Map(mi => mi.Id)
.ShouldBe(enumerator.Current.Map(mi => mi.Id), $"after {i} items");
enumerator.MoveNext(Option<DateTimeOffset>.None);
sawSecondCycle |= enumerator.State.Index == 0;
}
sawSecondCycle.ShouldBeTrue("the playlist should have reshuffled at least once");
}
// EnumeratorPlayAllCount is a record. Playlist items with one collection and equal settings
// compare equal, and the retry in ShufflePlaylistItems cannot end.
[Test]
public async Task Shuffled_Playlist_Of_Equal_Items_Should_Not_Hang()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{ PlaylistItemFor(1, collectionId: 1), [FakeMovie(1), FakeMovie(2)] },
{ PlaylistItemFor(2, collectionId: 1), [FakeMovie(1), FakeMovie(2)] },
{ PlaylistItemFor(3, collectionId: 1), [FakeMovie(1), FakeMovie(2)] }
};
// a token with a limit, so a fault fails the test and does not stop the test host
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState { Seed = 12345, Index = 0 },
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
cts.Token);
cts.IsCancellationRequested.ShouldBeFalse("ShufflePlaylistItems did not return on its own");
enumerator.Current.IsSome.ShouldBeTrue();
}
// Create can remove every playlist item, and the callers do not check Current first
[Test]
public async Task Empty_Playlist_Should_Not_Throw()
{
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.SeasonEpisode,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = 1
},
[SpecialEpisode(1), SpecialEpisode(2)]
}
};
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
new CollectionEnumeratorState { Seed = 12345, Index = 0 },
shufflePlaylistItems: false,
batchSize: Option<int>.None,
randomStartPoint: false,
CancellationToken.None);
enumerator.ChildEnumerators.ShouldBeEmpty();
enumerator.Current.IsNone.ShouldBeTrue();
Should.NotThrow(() => enumerator.MoveNext(Option<DateTimeOffset>.None));
Should.NotThrow(() => _ = enumerator.CurrentEnumeratorPlayAll);
Should.NotThrow(() => enumerator.SetEnumeratorIndex(0));
}
private static PlaylistItem PlaylistItemFor(int id, int? collectionId = null) => new()
{
Id = id,
Index = id - 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = collectionId ?? id
};
private static Episode SpecialEpisode(int id) => new()
{
Id = id,
Season = new Season { Id = 1, SeasonNumber = 0 },
SeasonId = 1,
EpisodeMetadata = [new EpisodeMetadata { EpisodeNumber = id }],
MediaVersions = []
};
private static Movie FakeMovie(int id) => new()
{
Id = id,

150
ErsatzTV.Core.Tests/Scheduling/PlaylistHistoryTests.cs

@ -87,6 +87,64 @@ public class PlaylistHistoryTests @@ -87,6 +87,64 @@ public class PlaylistHistoryTests
$"build 1 played [{string.Join(", ", playedInBuildOne)}]");
}
// a shuffled playlist gets a new seed at the end of each cycle.
// 6 shows with 2 episodes give a cycle of 12 items, and build 1 stops in cycle 2.
// the test needs 4 groups or more, because ShufflePlaylistItems refuses an order that starts with
// the last group of the previous order.
[Test]
public async Task Marathon_Should_Resume_Inside_A_Later_Cycle()
{
const int BUILD_ONE_COUNT = 14;
const int COMPARE_COUNT = 12;
IMediaCollectionRepository repo = FakeShowRepository(shows: 6, episodesPerShow: 2);
YamlPlayoutContentMarathonItem marathon = MarathonContent(shows: 6);
var definition = new YamlPlayoutDefinition { Content = [marathon] };
var playout = new Playout { Id = 1, Seed = PlayoutSeed, PlayoutHistory = [] };
var buildOneContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = Start };
var buildOneCache = new EnumeratorCache(repo, NullLogger.Instance);
PlaylistEnumerator buildOne = await GetPlaylistEnumerator(buildOneCache, buildOneContext, marathon.Key);
int seedAtStart = buildOne.State.Seed;
var playedInBuildOne = new List<int>();
var history = new List<PlayoutHistory>();
DateTimeOffset currentTime = Start;
for (var i = 0; i < BUILD_ONE_COUNT; i++)
{
playedInBuildOne.Add(CurrentId(buildOne));
history.AddRange(RecordHistory(buildOneContext, marathon.Key, buildOne, currentTime));
buildOne.MoveNext(currentTime);
currentTime += ItemDuration;
}
// the build must cross a cycle end, or the seed does not change and the test shows nothing
buildOne.State.Seed.ShouldNotBe(seedAtStart);
List<int> expected = Take(buildOne, COMPARE_COUNT);
var buildTwoContext = new YamlPlayoutContext(playout, definition, 1) { CurrentTime = currentTime };
var buildTwoCache = new EnumeratorCache(repo, NullLogger.Instance);
var applyHistory = new YamlPlayoutApplyHistoryHandler(buildTwoCache);
bool applied = await applyHistory.Handle(
history,
buildTwoContext,
marathon,
NullLogger<SequentialPlayoutBuilder>.Instance,
_cancellationToken);
applied.ShouldBeTrue();
PlaylistEnumerator buildTwo = await GetPlaylistEnumerator(buildTwoCache, buildTwoContext, marathon.Key);
List<int> actual = Take(buildTwo, COMPARE_COUNT);
actual.ShouldBe(
expected,
$"build 1 played [{string.Join(", ", playedInBuildOne)}]");
}
// the block scheduler restores playlist filler with its own copy of the same 3 lines
[Test]
public async Task Block_Playlist_Filler_Should_Resume_Where_The_Previous_Build_Stopped()
@ -233,6 +291,98 @@ public class PlaylistHistoryTests @@ -233,6 +291,98 @@ public class PlaylistHistoryTests
Take(buildTwo, COMPARE_COUNT).ShouldBe(expected);
}
// with uneven collection sizes, a cycle can end while a child is part way through its own list.
// a rewind to the cycle start does not make that position again.
// item_order is shuffle by default for a marathon.
// a shuffled child gets a new seed each time it wraps, so the replay cannot make that order again.
[Test]
[TestCase(PlaybackOrder.Chronological)]
[TestCase(PlaybackOrder.Shuffle)]
public async Task Shuffled_Playlist_Of_Uneven_Collections_Should_Resume_Inside_A_Later_Cycle(
PlaybackOrder itemPlaybackOrder)
{
const string HISTORY_KEY = "uneven-playlist";
IMediaCollectionRepository repo = FakeUnevenPlaylistRepository(itemPlaybackOrder);
Dictionary<PlaylistItem, List<MediaItem>> itemMap = await repo.GetPlaylistItemMap(1, _cancellationToken);
SchedulingEngine engine = CreateEngine(repo);
PlaylistEnumerator buildOne = await CreateShuffledPlaylistEnumerator(repo, itemMap, _cancellationToken);
int seedAtStart = buildOne.State.Seed;
DateTimeOffset currentTime = Start;
var history = new List<PlayoutHistory>();
var cycles = 0;
var played = 0;
// stop part way into the third cycle
while (cycles < 2 || played < 5)
{
history.Clear();
history.AddRange(ScriptedHistoryFor(engine, buildOne, HISTORY_KEY, currentTime));
buildOne.MoveNext(currentTime);
currentTime += ItemDuration;
if (cycles >= 2)
{
played++;
}
else if (buildOne.State.Index == 0)
{
cycles++;
}
}
buildOne.State.Seed.ShouldNotBe(seedAtStart, "the playlist should have reshuffled");
List<int> expected = Take(buildOne, 10);
PlaylistEnumerator buildTwo = await CreateShuffledPlaylistEnumerator(repo, itemMap, _cancellationToken);
ApplyScriptedHistory(engine, history, currentTime, HISTORY_KEY, itemMap, buildTwo);
Take(buildTwo, 10).ShouldBe(expected);
}
private static async Task<PlaylistEnumerator> CreateShuffledPlaylistEnumerator(
IMediaCollectionRepository repo,
Dictionary<PlaylistItem, List<MediaItem>> itemMap,
CancellationToken cancellationToken) =>
await PlaylistEnumerator.Create(
repo,
itemMap,
new CollectionEnumeratorState { Seed = PlayoutSeed, Index = 0 },
shufflePlaylistItems: true,
batchSize: Option<int>.None,
randomStartPoint: false,
cancellationToken);
private static IMediaCollectionRepository FakeUnevenPlaylistRepository(PlaybackOrder itemPlaybackOrder)
{
int[] sizes = [2, 3, 2, 4];
Dictionary<PlaylistItem, List<MediaItem>> itemMap = Enumerable.Range(1, sizes.Length)
.ToDictionary(
collectionId => new PlaylistItem
{
Id = collectionId,
Index = collectionId - 1,
PlaybackOrder = itemPlaybackOrder,
PlayAll = false,
CollectionType = CollectionType.Collection,
CollectionId = collectionId,
IncludeInProgramGuide = true
},
collectionId => Enumerable.Range(0, sizes[collectionId - 1])
.Map(i => (MediaItem)FakeMovie(collectionId * 100 + i))
.ToList());
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
repo.GetPlaylistItemMap(1, Arg.Any<CancellationToken>()).Returns(_ => Task.FromResult(itemMap));
return repo;
}
private static async Task<PlaylistEnumerator> CreatePlaylistEnumerator(
IMediaCollectionRepository repo,
Dictionary<PlaylistItem, List<MediaItem>> itemMap,

4
ErsatzTV.Core/Domain/Scheduling/PlayoutHistory.cs

@ -12,6 +12,9 @@ public class PlayoutHistory @@ -12,6 +12,9 @@ public class PlayoutHistory
public PlaybackOrder PlaybackOrder { get; set; }
public int Index { get; set; }
// the cycle that Index counts in. A row from before this column has null.
public int? Seed { get; set; }
// something that uniquely identifies the collection within the block
public string Key { get; set; }
@ -37,6 +40,7 @@ public class PlayoutHistory @@ -37,6 +40,7 @@ public class PlayoutHistory
BlockId = BlockId,
PlaybackOrder = PlaybackOrder,
Index = Index,
Seed = Seed,
Key = Key,
ChildKey = ChildKey,
IsCurrentChild = IsCurrentChild,

18
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutEnumerator.cs

@ -196,11 +196,10 @@ public static class BlockPlayoutEnumerator @@ -196,11 +196,10 @@ public static class BlockPlayoutEnumerator
enumerator.ResetState(
new CollectionEnumeratorState
{
Seed = enumerator.State.Seed,
Seed = primaryHistory.Seed ?? enumerator.State.Seed,
Index = primaryHistory.Index
});
var childEnumeratorKeys = enumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in
enumerator.ChildEnumerators)
{
@ -231,6 +230,19 @@ public static class BlockPlayoutEnumerator @@ -231,6 +230,19 @@ public static class BlockPlayoutEnumerator
// h.Details,
// h.IsCurrentChild);
// a shuffled child gets a new seed each time it wraps.
// the replay from the cycle start cannot make that order again.
if (itemPlaybackOrder is PlaybackOrder.Shuffle)
{
childEnumerator.ResetState(
new CollectionEnumeratorState
{
Seed = h.Seed ?? childEnumerator.State.Seed,
Index = h.Index,
Started = childEnumerator.State.Started
});
}
// the collection may have changed since the last build, so the replayed
// position can point at the wrong item
if (itemPlaybackOrder is PlaybackOrder.Chronological)
@ -246,7 +258,7 @@ public static class BlockPlayoutEnumerator @@ -246,7 +258,7 @@ public static class BlockPlayoutEnumerator
// the playlist order may have changed since the last build
if (h.IsCurrentChild)
{
enumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
enumerator.EnsureCurrentChild(collectionKey);
}
}
}

2
ErsatzTV.Core/Scheduling/BlockScheduling/BlockPlayoutFillerBuilder.cs

@ -239,6 +239,7 @@ public class BlockPlayoutFillerBuilder( @@ -239,6 +239,7 @@ public class BlockPlayoutFillerBuilder(
PlayoutId = playout.Id,
PlaybackOrder = PlaybackOrder.None,
Index = enumerator.State.Index,
Seed = enumerator.State.Seed,
When = blockStart.UtcDateTime,
Finish = filler.FinishOffset.UtcDateTime,
Key = historyKey,
@ -414,6 +415,7 @@ public class BlockPlayoutFillerBuilder( @@ -414,6 +415,7 @@ public class BlockPlayoutFillerBuilder(
PlayoutId = playout.Id,
PlaybackOrder = PlaybackOrder.Shuffle,
Index = enumerator.State.Index,
Seed = enumerator.State.Seed,
When = current.UtcDateTime,
Finish = filler.FinishOffset.UtcDateTime,
Key = historyKey,

6
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -39,7 +39,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu @@ -39,7 +39,7 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter in chronological
State.Index = state.Index;
State.Index = ClampIndex(state.Index);
State.Started = state.Started;
}
@ -84,4 +84,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu @@ -84,4 +84,8 @@ public sealed class ChronologicalMediaCollectionEnumerator : IMediaCollectionEnu
public int GroupSizeForMediaItem(MediaItem mediaItem) =>
_lazyMediaItemGroupSize.Value.GetValueOrDefault(mediaItem.Id, 1);
// the history can give an index that this collection does not have
private int ClampIndex(int index) =>
_sortedMediaItems.Count == 0 || index < 0 ? 0 : index % _sortedMediaItems.Count;
}

6
ErsatzTV.Core/Scheduling/CustomOrderCollectionEnumerator.cs

@ -35,7 +35,7 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -35,7 +35,7 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here
State.Index = state.Index;
State.Index = ClampIndex(state.Index);
State.Started = state.Started;
}
@ -55,4 +55,8 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator @@ -55,4 +55,8 @@ public class CustomOrderCollectionEnumerator : IMediaCollectionEnumerator
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count;
// the history can give an index that this collection does not have
private int ClampIndex(int index) =>
_sortedMediaItems.Count == 0 || index < 0 ? 0 : index % _sortedMediaItems.Count;
}

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

@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.Engine; @@ -7,6 +7,8 @@ namespace ErsatzTV.Core.Scheduling.Engine;
public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository)
{
public static readonly string[] ValidGroupByValues = ["show", "season", "artist", "album", "director"];
public async Task<Option<PlaylistEnumerator>> GetEnumerator(
List<MediaItem> mediaItems,
MarathonGroupBy marathonGroupBy,
@ -94,6 +96,9 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -94,6 +96,9 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
allMediaItems.AddRange(await mediaCollectionRepository.GetSmartCollectionItems(query, string.Empty, cancellationToken));
}
// an item can come from more than one guid or search. A duplicate then plays two times.
allMediaItems = allMediaItems.DistinctBy(i => i.Id).ToList();
List<IGrouping<GroupKey, MediaItem>> groups = [];
// group by show
@ -121,6 +126,11 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -121,6 +126,11 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
{
groups.AddRange(allMediaItems.GroupBy(MediaItemKeyByDirector));
}
else
{
// the enum overload also returns None for a group that it does not know
return Option<PlaylistContentResult>.None;
}
Dictionary<PlaylistItem, List<MediaItem>> itemMap = [];
@ -186,7 +196,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository @@ -186,7 +196,7 @@ public class MarathonHelper(IMediaCollectionRepository mediaCollectionRepository
{
Song s => new GroupKey(
CollectionType.Collection,
s.SongMetadata.HeadOrNone().Map(sm => sm.Album.GetStableHashCode()).IfNone(0),
s.SongMetadata.HeadOrNone().Map(sm => (sm.Album ?? string.Empty).GetStableHashCode()).IfNone(0),
null,
null,
null),

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

@ -175,6 +175,15 @@ public class SchedulingEngine( @@ -175,6 +175,15 @@ public class SchedulingEngine(
state,
CancellationToken.None);
if (maybeResult.IsNone)
{
logger.LogWarning(
"Unable to add marathon with key {Key}; group by {GroupBy} is not one of {ValidValues}",
key,
groupBy,
MarathonHelper.ValidGroupByValues);
}
foreach (PlaylistContentResult result in maybeResult)
{
foreach (PlaylistEnumerator enumerator in Optional(result.PlaylistEnumerator))
@ -1273,11 +1282,10 @@ public class SchedulingEngine( @@ -1273,11 +1282,10 @@ public class SchedulingEngine(
playlistEnumerator.ResetState(
new CollectionEnumeratorState
{
Seed = playlistEnumerator.State.Seed,
Seed = primaryHistory.Seed ?? playlistEnumerator.State.Seed,
Index = primaryHistory.Index
});
var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in
playlistEnumerator.ChildEnumerators)
{
@ -1308,6 +1316,19 @@ public class SchedulingEngine( @@ -1308,6 +1316,19 @@ public class SchedulingEngine(
// h.Details,
// h.IsCurrentChild);
// a shuffled child gets a new seed each time it wraps.
// the replay from the cycle start cannot make that order again.
if (itemPlaybackOrder is PlaybackOrder.Shuffle)
{
childEnumerator.ResetState(
new CollectionEnumeratorState
{
Seed = h.Seed ?? childEnumerator.State.Seed,
Index = h.Index,
Started = childEnumerator.State.Started
});
}
// the collection may have changed since the last build, so the replayed
// position can point at the wrong item
if (itemPlaybackOrder is PlaybackOrder.Chronological)
@ -1323,7 +1344,7 @@ public class SchedulingEngine( @@ -1323,7 +1344,7 @@ public class SchedulingEngine(
// the playlist order may have changed since the last build
if (h.IsCurrentChild)
{
playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
playlistEnumerator.EnsureCurrentChild(collectionKey);
}
}
}
@ -1401,6 +1422,7 @@ public class SchedulingEngine( @@ -1401,6 +1422,7 @@ public class SchedulingEngine(
PlayoutId = _state.PlayoutId,
PlaybackOrder = enumeratorDetails.PlaybackOrder,
Index = playlistEnumerator.State.Index,
Seed = playlistEnumerator.State.Seed,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = enumeratorDetails.HistoryKey,
@ -1422,6 +1444,7 @@ public class SchedulingEngine( @@ -1422,6 +1444,7 @@ public class SchedulingEngine(
PlayoutId = _state.PlayoutId,
PlaybackOrder = enumeratorDetails.PlaybackOrder,
Index = childEnumerator.State.Index,
Seed = childEnumerator.State.Seed,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = enumeratorDetails.HistoryKey,

33
ErsatzTV.Core/Scheduling/HistoryDetails.cs

@ -161,14 +161,26 @@ internal static class HistoryDetails @@ -161,14 +161,26 @@ internal static class HistoryDetails
}
Option<MediaItem> maybeMatchedItem = Option<MediaItem>.None;
var copy = collectionItems.ToList();
// the index must agree with the list that the enumerator sorted.
// that enumerator removes season 0, so this list must remove it too.
List<MediaItem> copy = enumerator is SeasonEpisodeMediaCollectionEnumerator
? SeasonEpisodeMediaCollectionEnumerator.Playable(collectionItems)
: collectionItems.ToList();
if (copy.Count == 0)
{
return;
}
Details details = JsonConvert.DeserializeObject<Details>(detailsString);
MediaItem placeholder = null;
// try for an exact match first
if (details.MediaItemId != null)
{
maybeMatchedItem = collectionItems.Find(mi => mi.Id == details.MediaItemId);
maybeMatchedItem = copy.Find(mi => mi.Id == details.MediaItemId);
}
if (maybeMatchedItem.IsNone && details.SeasonNumber.HasValue && details.EpisodeNumber.HasValue)
@ -176,7 +188,7 @@ internal static class HistoryDetails @@ -176,7 +188,7 @@ internal static class HistoryDetails
int season = details.SeasonNumber.Value;
int episode = details.EpisodeNumber.Value;
maybeMatchedItem = Optional(collectionItems.Find(ci => MatchSeasonAndEpisode(ci, season, episode)));
maybeMatchedItem = Optional(copy.Find(ci => MatchSeasonAndEpisode(ci, season, episode)));
if (maybeMatchedItem.IsNone)
{
@ -195,18 +207,20 @@ internal static class HistoryDetails @@ -195,18 +207,20 @@ internal static class HistoryDetails
copy.Add(fakeItem);
maybeMatchedItem = fakeItem;
placeholder = fakeItem;
}
}
else if (maybeMatchedItem.IsNone && playbackOrder is PlaybackOrder.Chronological &&
details.ReleaseDate.HasValue)
{
maybeMatchedItem = Optional(collectionItems.Find(ci => MatchReleaseDate(ci, details.ReleaseDate.Value)));
maybeMatchedItem = Optional(copy.Find(ci => MatchReleaseDate(ci, details.ReleaseDate.Value)));
if (maybeMatchedItem.IsNone)
{
var fakeItem = new Movie { MovieMetadata = [new MovieMetadata { ReleaseDate = details.ReleaseDate }] };
copy.Add(fakeItem);
maybeMatchedItem = fakeItem;
placeholder = fakeItem;
}
}
@ -220,10 +234,19 @@ internal static class HistoryDetails @@ -220,10 +234,19 @@ internal static class HistoryDetails
copy.Sort(comparer);
int index = copy.IndexOf(matchedItem);
if (placeholder is not null)
{
// the enumerator does not hold the placeholder.
// its position is where the next real item is, and that position can be past the end.
copy.Remove(placeholder);
index %= copy.Count;
}
var state = new CollectionEnumeratorState
{
Seed = enumerator.State.Seed,
Index = copy.IndexOf(matchedItem)
Index = index
};
enumerator.ResetState(state);

110
ErsatzTV.Core/Scheduling/PlaylistEnumerator.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Scheduling; @@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Scheduling;
public class PlaylistEnumerator : IMediaCollectionEnumerator
{
private readonly System.Collections.Generic.HashSet<int> _remainingMediaItemIds = [];
private CancellationToken _cancellationToken;
private System.Collections.Generic.HashSet<int> _allMediaItemIds;
private System.Collections.Generic.HashSet<int> _idsToIncludeInEPG;
private CloneableRandom _random;
@ -30,9 +31,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -30,9 +31,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int CountForFiller => _sortedEnumerators.Select(t => t.PlayAll ? t.Enumerator.Count : t.Count ?? 1).Sum();
public ImmutableList<PlaylistEnumeratorCollectionKey> ChildEnumerators { get; private set; }
// callers use EnumeratorIndex on this list, so it must hold the shuffled order
public ImmutableList<PlaylistEnumeratorCollectionKey> ChildEnumerators { get; private set; } =
ImmutableList<PlaylistEnumeratorCollectionKey>.Empty;
public bool CurrentEnumeratorPlayAll => _sortedEnumerators[EnumeratorIndex].PlayAll;
public bool CurrentEnumeratorPlayAll =>
_sortedEnumerators.Count > 0 && _sortedEnumerators[EnumeratorIndex].PlayAll;
public int EnumeratorIndex { get; private set; }
@ -76,6 +80,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -76,6 +80,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public void MoveNext(Option<DateTimeOffset> scheduledAt)
{
// Create removes a playlist item that has no playable content, so the list can be empty
if (_sortedEnumerators.Count == 0)
{
return;
}
foreach (MediaItem maybeMediaItem in _sortedEnumerators[EnumeratorIndex].Enumerator.Current)
{
_remainingMediaItemIds.Remove(maybeMediaItem.Id);
@ -122,14 +132,44 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -122,14 +132,44 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
{
State.Seed = _random.Next();
_random = new CloneableRandom(State.Seed);
_sortedEnumerators = ShufflePlaylistItems();
SetSortedEnumerators(ShufflePlaylistItems());
}
}
State.Started = true;
}
public void SetEnumeratorIndex(int enumeratorIndex) => EnumeratorIndex = enumeratorIndex % _sortedEnumerators.Count;
public void SetEnumeratorIndex(int enumeratorIndex)
{
if (_sortedEnumerators.Count == 0)
{
return;
}
int newIndex = enumeratorIndex % _sortedEnumerators.Count;
if (newIndex != EnumeratorIndex)
{
EnumeratorIndex = newIndex;
// these counts are for the previous child
_itemsTakenFromCurrent = 0;
}
}
// more than one playlist item can use one collection, and IndexOf gives the first of them
public void EnsureCurrentChild(CollectionKey collectionKey)
{
if (_sortedEnumerators.Count == 0 || _sortedEnumerators[EnumeratorIndex].CollectionKey == collectionKey)
{
return;
}
int index = _sortedEnumerators.FindIndex(e => e.CollectionKey == collectionKey);
if (index >= 0)
{
SetEnumeratorIndex(index);
}
}
public static async Task<PlaylistEnumerator> Create(
IMediaCollectionRepository mediaCollectionRepository,
@ -145,7 +185,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -145,7 +185,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
_sortedEnumerators = [],
_idsToIncludeInEPG = [],
_shufflePlaylistItems = shufflePlaylistItems,
_batchSize = batchSize
_batchSize = batchSize,
_cancellationToken = cancellationToken
};
// random start points must be applied on every build, not just the first
@ -169,7 +210,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -169,7 +210,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
if (enumeratorMap.TryGetValue(collectionKey, out IMediaCollectionEnumerator enumerator))
{
result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
new EnumeratorPlayAllCount(
enumerator,
collectionKey,
playlistItem.PlayAll,
playlistItem.Count));
result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide);
continue;
}
@ -208,6 +253,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -208,6 +253,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
await PlayoutBuilder.GetCollectionItemsForShuffleInOrder(
mediaCollectionRepository,
CollectionKey.ForPlaylistItem(playlistItem),
items,
cancellationToken),
initState,
randomStartPoint,
@ -237,7 +283,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -237,7 +283,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
{
enumeratorMap.Add(collectionKey, enumerator);
result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
new EnumeratorPlayAllCount(
enumerator,
collectionKey,
playlistItem.PlayAll,
playlistItem.Count));
result.TrackMediaItemIds(items, playlistItem.IncludeInProgramGuide);
}
}
@ -259,10 +309,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -259,10 +309,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
result._random = new CloneableRandom(state.Seed);
if (shufflePlaylistItems)
{
result._sortedEnumerators = result.ShufflePlaylistItems();
}
result.SetSortedEnumerators(
shufflePlaylistItems ? result.ShufflePlaylistItems() : result._sortedEnumerators);
result.State = new CollectionEnumeratorState { Seed = state.Seed, Started = state.Started };
result.EnumeratorIndex = 0;
@ -275,18 +323,17 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -275,18 +323,17 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
result.ReplayTo(state.Index);
var childEnumerators = new List<PlaylistEnumeratorCollectionKey>();
foreach ((IMediaCollectionEnumerator enumerator, _, _) in result._sortedEnumerators)
{
foreach ((CollectionKey collectionKey, _) in enumeratorMap.Find(e => e.Value == enumerator))
{
childEnumerators.Add(new PlaylistEnumeratorCollectionKey(enumerator, collectionKey));
}
return result;
}
result.ChildEnumerators = childEnumerators.ToImmutableList();
return result;
// the only writer of _sortedEnumerators, so ChildEnumerators always agrees with it
private void SetSortedEnumerators(List<EnumeratorPlayAllCount> sortedEnumerators)
{
_sortedEnumerators = sortedEnumerators;
ChildEnumerators =
[
.. sortedEnumerators.Map(e => new PlaylistEnumeratorCollectionKey(e.Enumerator, e.CollectionKey))
];
}
private void TrackMediaItemIds(List<MediaItem> items, bool includeInProgramGuide)
@ -311,13 +358,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -311,13 +358,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
enumerator.ResetState(childState.Clone());
}
_sortedEnumerators = [.. _enumeratorsInPlaylistOrder];
_random = new CloneableRandom(seed);
if (_shufflePlaylistItems)
{
_sortedEnumerators = ShufflePlaylistItems();
}
SetSortedEnumerators(_shufflePlaylistItems ? ShufflePlaylistItems() : [.. _enumeratorsInPlaylistOrder]);
_remainingMediaItemIds.Clear();
_remainingMediaItemIds.UnionWith(_allMediaItemIds);
@ -360,6 +402,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -360,6 +402,12 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
EnumeratorPlayAllCount[] copy = _enumeratorsInPlaylistOrder.ToArray();
EnumeratorPlayAllCount last = _enumeratorsInPlaylistOrder.Last();
// a record compares by value. If every entry equals the last one, the loop below cannot end.
if (_enumeratorsInPlaylistOrder.All(e => e == last))
{
return [.. _enumeratorsInPlaylistOrder];
}
do
{
int n = copy.Length;
@ -369,10 +417,14 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator @@ -369,10 +417,14 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
int k = _random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
} while (copy.First() == last);
} while (!_cancellationToken.IsCancellationRequested && copy.First() == last);
return copy.ToList();
}
private record EnumeratorPlayAllCount(IMediaCollectionEnumerator Enumerator, bool PlayAll, int? Count);
private record EnumeratorPlayAllCount(
IMediaCollectionEnumerator Enumerator,
CollectionKey CollectionKey,
bool PlayAll,
int? Count);
}

37
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -1309,6 +1309,16 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1309,6 +1309,16 @@ public class PlayoutBuilder : IPlayoutBuilder
? await _mediaCollectionRepository.GetPlaylistItemMap(DebugPlaylist, cancellationToken)
: await _mediaCollectionRepository.GetPlaylistItemMap(playlistId, cancellationToken);
// this reads the item map again, so the items that CheckForEmptyCollections removed are back
if (DebugPlaylist is null && mediaItems.Count > 0 && !mediaItems.Any(i => i is ChapterMediaItem))
{
var allowedIds = mediaItems.Map(i => i.Id).ToHashSet();
playlistItemMap = playlistItemMap
.Map(kvp => (kvp.Key, Items: kvp.Value.Filter(i => allowedIds.Contains(i.Id)).ToList()))
.Filter(x => x.Items.Count > 0)
.ToDictionary(x => x.Key, x => x.Items);
}
return await PlaylistEnumerator.Create(
_mediaCollectionRepository,
playlistItemMap,
@ -1370,6 +1380,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1370,6 +1380,7 @@ public class PlayoutBuilder : IPlayoutBuilder
await GetCollectionItemsForShuffleInOrder(
_mediaCollectionRepository,
collectionKey,
mediaItems,
cancellationToken),
state,
activeSchedule.RandomStartPoint,
@ -1445,6 +1456,27 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1445,6 +1456,27 @@ public class PlayoutBuilder : IPlayoutBuilder
}
}
// the callers read the collections again, so the items that CheckForEmptyCollections removed
// from the playout list come back
private static List<CollectionWithItems> RestrictToPlayoutItems(
List<CollectionWithItems> collections,
List<MediaItem> playoutItems)
{
// UseChaptersAsMediaItems gives the playout list new ids that no collection has.
// an empty list is a caller with no filter, not a filter that removes everything.
if (playoutItems.Count == 0 || playoutItems.Any(i => i is ChapterMediaItem))
{
return collections;
}
var allowedIds = playoutItems.Map(i => i.Id).ToHashSet();
return collections
.Map(c => c with { MediaItems = c.MediaItems.Filter(i => allowedIds.Contains(i.Id)).ToList() })
.Filter(c => c.MediaItems.Count > 0)
.ToList();
}
internal static async Task<List<GroupedMediaItem>> GetGroupedMediaItemsForShuffle(
IMediaCollectionRepository mediaCollectionRepository,
ProgramSchedule activeSchedule,
@ -1457,7 +1489,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1457,7 +1489,7 @@ public class PlayoutBuilder : IPlayoutBuilder
List<CollectionWithItems> collections = await mediaCollectionRepository
.GetMultiCollectionCollections(collectionKey.MultiCollectionId.Value, cancellationToken);
return MultiCollectionGrouper.GroupMediaItems(collections);
return MultiCollectionGrouper.GroupMediaItems(RestrictToPlayoutItems(collections, mediaItems));
}
return activeSchedule.KeepMultiPartEpisodesTogether
@ -1468,6 +1500,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1468,6 +1500,7 @@ public class PlayoutBuilder : IPlayoutBuilder
internal static async Task<List<CollectionWithItems>> GetCollectionItemsForShuffleInOrder(
IMediaCollectionRepository mediaCollectionRepository,
CollectionKey collectionKey,
List<MediaItem> mediaItems,
CancellationToken cancellationToken)
{
List<CollectionWithItems> result;
@ -1486,7 +1519,7 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -1486,7 +1519,7 @@ public class PlayoutBuilder : IPlayoutBuilder
cancellationToken);
}
return result;
return RestrictToPlayoutItems(result, mediaItems);
}
internal static string DisplayTitle(MediaItem mediaItem)

17
ErsatzTV.Core/Scheduling/PlayoutModeSchedulerBase.cs

@ -177,6 +177,11 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode @@ -177,6 +177,11 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
TimeSpan inPoint = InPointForMediaItem(mediaItem);
if (itemDuration <= TimeSpan.Zero)
{
break;
}
if (nextState.CurrentTime + itemDuration > nextItemStart)
{
warnings.TailFillerTooLong++;
@ -233,6 +238,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode @@ -233,6 +238,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
MediaItem mediaItem = enumerator.Current.ValueUnsafe();
TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
if (itemDuration <= TimeSpan.Zero)
{
break;
}
TimeSpan gap = nextItemStart - nextState.CurrentTime;
TimeSpan duration = itemDuration < gap ? itemDuration : gap;
TimeSpan inPoint = InPointForMediaItem(mediaItem);
@ -911,6 +922,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode @@ -911,6 +922,12 @@ public abstract class PlayoutModeSchedulerBase<T>(ILogger logger) : IPlayoutMode
{
MediaItem mediaItem = enumerator.Current.ValueUnsafe();
TimeSpan itemDuration = mediaItem.GetDurationForPlayout();
if (itemDuration <= TimeSpan.Zero)
{
break;
}
TimeSpan currentDuration = itemDuration < remainingToFill ? itemDuration : remainingToFill;
TimeSpan inPoint = InPointForMediaItem(mediaItem);

6
ErsatzTV.Core/Scheduling/SeasonEpisodeMediaCollectionEnumerator.cs

@ -41,7 +41,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu @@ -41,7 +41,7 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
public void ResetState(CollectionEnumeratorState state)
{
// seed doesn't matter here
State.Index = state.Index;
State.Index = ClampIndex(state.Index);
State.Started = state.Started;
}
@ -66,4 +66,8 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu @@ -66,4 +66,8 @@ public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnu
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _sortedMediaItems.Count;
// the history can give an index that this collection does not have
private int ClampIndex(int index) =>
_sortedMediaItems.Count == 0 || index < 0 ? 0 : index % _sortedMediaItems.Count;
}

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

@ -131,6 +131,15 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor @@ -131,6 +131,15 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
state,
cancellationToken);
if (maybeResult.IsNone)
{
logger.LogWarning(
"Unable to add marathon with key {Key}; group_by {GroupBy} is not one of {ValidValues}",
contentKey,
marathon.GroupBy,
MarathonHelper.ValidGroupByValues);
}
foreach (PlaylistContentResult result in maybeResult)
{
foreach ((CollectionKey collectionKey, List<MediaItem> mediaItems) in result.Content)

18
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutApplyHistoryHandler.cs

@ -67,11 +67,10 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) @@ -67,11 +67,10 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache)
playlistEnumerator.ResetState(
new CollectionEnumeratorState
{
Seed = playlistEnumerator.State.Seed,
Seed = primaryHistory.Seed ?? playlistEnumerator.State.Seed,
Index = primaryHistory.Index
});
var childEnumeratorKeys = playlistEnumerator.ChildEnumerators.Map(x => x.CollectionKey).ToList();
foreach ((IMediaCollectionEnumerator childEnumerator, CollectionKey collectionKey) in
playlistEnumerator.ChildEnumerators)
{
@ -103,6 +102,19 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) @@ -103,6 +102,19 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache)
// h.Details,
// h.IsCurrentChild);
// a shuffled child gets a new seed each time it wraps.
// the replay from the cycle start cannot make that order again.
if (itemPlaybackOrder is PlaybackOrder.Shuffle)
{
childEnumerator.ResetState(
new CollectionEnumeratorState
{
Seed = h.Seed ?? childEnumerator.State.Seed,
Index = h.Index,
Started = childEnumerator.State.Started
});
}
// the collection may have changed since the last build, so the replayed
// position can point at the wrong item
if (itemPlaybackOrder is PlaybackOrder.Chronological)
@ -118,7 +130,7 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache) @@ -118,7 +130,7 @@ public class YamlPlayoutApplyHistoryHandler(EnumeratorCache enumeratorCache)
// the playlist order may have changed since the last build
if (h.IsCurrentChild)
{
playlistEnumerator.SetEnumeratorIndex(childEnumeratorKeys.IndexOf(collectionKey));
playlistEnumerator.EnsureCurrentChild(collectionKey);
}
}
}

2
ErsatzTV.Core/Scheduling/YamlScheduling/Handlers/YamlPlayoutContentHandler.cs

@ -84,6 +84,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache) @@ -84,6 +84,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache)
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = playlistEnumerator.State.Index,
Seed = playlistEnumerator.State.Seed,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,
@ -105,6 +106,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache) @@ -105,6 +106,7 @@ public abstract class YamlPlayoutContentHandler(EnumeratorCache enumeratorCache)
PlayoutId = context.Playout.Id,
PlaybackOrder = playbackOrder,
Index = childEnumerator.State.Index,
Seed = childEnumerator.State.Seed,
When = playoutItem.StartOffset.UtcDateTime,
Finish = playoutItem.FinishOffset.UtcDateTime,
Key = historyKey,

7066
ErsatzTV.Infrastructure.MySql/Migrations/20260831123102_Add_PlayoutHistory_Seed.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.MySql/Migrations/20260831123102_Add_PlayoutHistory_Seed.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutHistory_Seed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Seed",
table: "PlayoutHistory",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Seed",
table: "PlayoutHistory");
}
}
}

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

@ -3076,6 +3076,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations @@ -3076,6 +3076,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("PlayoutId")
.HasColumnType("int");
b.Property<int?>("Seed")
.HasColumnType("int");
b.Property<DateTime>("When")
.HasColumnType("datetime(6)");

6893
ErsatzTV.Infrastructure.Sqlite/Migrations/20260831123022_Add_PlayoutHistory_Seed.Designer.cs generated

File diff suppressed because it is too large Load Diff

28
ErsatzTV.Infrastructure.Sqlite/Migrations/20260831123022_Add_PlayoutHistory_Seed.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlayoutHistory_Seed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Seed",
table: "PlayoutHistory",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Seed",
table: "PlayoutHistory");
}
}
}

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

@ -2941,6 +2941,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations @@ -2941,6 +2941,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int?>("Seed")
.HasColumnType("INTEGER");
b.Property<DateTime>("When")
.HasColumnType("TEXT");

6
ErsatzTV/Controllers/Api/ScriptedScheduleController.cs

@ -68,6 +68,12 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP @@ -68,6 +68,12 @@ public class ScriptedScheduleController(IScriptedPlayoutBuilderService scriptedP
return BadRequest("Invalid item playback order.");
}
if (!MarathonHelper.ValidGroupByValues.Contains(request.GroupBy, StringComparer.OrdinalIgnoreCase))
{
return BadRequest(
$"Invalid group by; valid values are {string.Join(", ", MarathonHelper.ValidGroupByValues)}.");
}
await engine.AddMarathon(
request.Key,
request.Guids ?? [],

Loading…
Cancel
Save