allow shuffling block items (#1571)

* allow shuffling block items

* fix drop down search results
This commit is contained in:
Jason Dove
2024-01-23 22:42:28 -06:00
committed by GitHub
parent d37ce2d38a
commit 12234c3e21
39 changed files with 15380 additions and 87 deletions
@@ -36,6 +36,7 @@ public class BlockPlayoutBuilder(
[
PlaybackOrder.Chronological,
PlaybackOrder.SeasonEpisode,
PlaybackOrder.Shuffle,
PlaybackOrder.Random
];
@@ -177,6 +178,8 @@ public class BlockPlayoutBuilder(
{
PlayoutId = playout.Id,
BlockId = blockItem.BlockId,
PlaybackOrder = blockItem.PlaybackOrder,
Seed = enumerator.State.Seed,
When = currentTime.UtcDateTime,
Key = historyKey,
Details = HistoryDetails.ForMediaItem(mediaItem)
@@ -212,41 +215,37 @@ public class BlockPlayoutBuilder(
string historyKey,
Map<CollectionKey, List<MediaItem>> collectionMediaItems)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var collectionKey = CollectionKey.ForBlockItem(blockItem);
List<MediaItem> collectionItems = collectionMediaItems[collectionKey];
// get enumerator
IMediaCollectionEnumerator enumerator = blockItem.PlaybackOrder switch
{
PlaybackOrder.Chronological => new ChronologicalMediaCollectionEnumerator(collectionItems, state),
PlaybackOrder.SeasonEpisode => new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state),
PlaybackOrder.Chronological => BlockPlayoutEnumerator.Chronological(
collectionItems,
currentTime,
playout,
blockItem,
historyKey,
Logger),
PlaybackOrder.SeasonEpisode => BlockPlayoutEnumerator.SeasonEpisode(
collectionItems,
currentTime,
playout,
blockItem,
historyKey,
Logger),
PlaybackOrder.Shuffle => BlockPlayoutEnumerator.Shuffle(
collectionItems,
currentTime,
playout,
blockItem,
historyKey),
_ => new RandomizedMediaCollectionEnumerator(
collectionItems,
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
};
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory history in maybeHistory)
{
Logger.LogDebug("History is applicable: {When}: {History}", history.When, history.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
history.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
@@ -288,8 +287,20 @@ public class BlockPlayoutBuilder(
IEnumerable<PlayoutHistory> toDelete = group
.Filter(h => h.When < start.UtcDateTime)
.OrderByDescending(h => h.When)
.Tail();
.OrderByDescending(h => h.When);
// chronological and season, episode only need to keep most recent entry
if (group.Count > 0 && group[0].PlaybackOrder is PlaybackOrder.Chronological or PlaybackOrder.SeasonEpisode)
{
toDelete = toDelete.Tail();
}
// shuffle needs to keep all entries with current seed
if (group.Count > 0 && group[0].PlaybackOrder is PlaybackOrder.Shuffle)
{
int currentSeed = group[0].Seed;
toDelete = toDelete.Filter(h => h.Seed != currentSeed);
}
foreach (PlayoutHistory delete in toDelete)
{
@@ -0,0 +1,152 @@
using System.Collections.Immutable;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace ErsatzTV.Core.Scheduling.BlockScheduling;
public static class BlockPlayoutEnumerator
{
public static IMediaCollectionEnumerator Chronological(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey,
ILogger logger)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var enumerator = new ChronologicalMediaCollectionEnumerator(collectionItems, state);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
public static IMediaCollectionEnumerator SeasonEpisode(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey,
ILogger logger)
{
DateTime historyTime = currentTime.UtcDateTime;
Option<PlayoutHistory> maybeHistory = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.HeadOrNone();
var state = new CollectionEnumeratorState { Seed = 0, Index = 0 };
var enumerator = new SeasonEpisodeMediaCollectionEnumerator(collectionItems, state);
// seek to the appropriate place in the collection enumerator
foreach (PlayoutHistory h in maybeHistory)
{
logger.LogDebug("History is applicable: {When}: {History}", h.When, h.Details);
HistoryDetails.MoveToNextItem(
collectionItems,
h.Details,
enumerator,
blockItem.PlaybackOrder);
}
return enumerator;
}
public static IMediaCollectionEnumerator Shuffle(
List<MediaItem> collectionItems,
DateTimeOffset currentTime,
Playout playout,
BlockItem blockItem,
string historyKey)
{
// need a new shuffled media collection enumerator that can "hide" items for one iteration, then include all items again
// maybe take a "masked items" hash set, then clear it after shuffling
DateTime historyTime = currentTime.UtcDateTime;
var maskedMediaItemIds = new System.Collections.Generic.HashSet<int>();
List<PlayoutHistory> history = playout.PlayoutHistory
.Filter(h => h.BlockId == blockItem.BlockId)
.Filter(h => h.Key == historyKey)
.Filter(h => h.When < historyTime)
.OrderByDescending(h => h.When)
.ToList();
if (history.Count > 0)
{
int currentSeed = history[0].Seed;
history = history.Filter(h => h.Seed == currentSeed).ToList();
}
var knownMediaIds = collectionItems.Map(ci => ci.Id).ToImmutableHashSet();
foreach (PlayoutHistory h in history)
{
HistoryDetails.Details details = JsonConvert.DeserializeObject<HistoryDetails.Details>(h.Details);
foreach (int mediaItemId in Optional(details.MediaItemId))
{
if (knownMediaIds.Contains(mediaItemId))
{
maskedMediaItemIds.Add(mediaItemId);
}
}
}
var state = new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 };
// keep the current seed if one exists
if (maskedMediaItemIds.Count > 0 && maskedMediaItemIds.Count < collectionItems.Count && history.Count > 0)
{
state.Seed = history[0].Seed;
}
// if everything is masked, nothing is masked
if (maskedMediaItemIds.Count == collectionItems.Count)
{
maskedMediaItemIds.Clear();
}
// TODO: fix multi-collection groups, keep multi-part episodes together
var mediaItems = collectionItems
.Map(mi => new GroupedMediaItem(mi, null))
.ToList();
Serilog.Log.Logger.Debug(
"scheduling {X} media items with {Y} masked",
mediaItems.Count,
maskedMediaItemIds.Count);
// it shouldn't matter which order the remaining items are shuffled in,
// as long as already-played items are not included
return new MaskedShuffledMediaCollectionEnumerator(
mediaItems,
maskedMediaItemIds,
state,
CancellationToken.None);
}
}
@@ -0,0 +1,106 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Scheduling;
namespace ErsatzTV.Core.Scheduling;
public class MaskedShuffledMediaCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly CancellationToken _cancellationToken;
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
private readonly int _mediaItemCount;
private readonly IList<GroupedMediaItem> _mediaItems;
private CloneableRandom _random;
private IList<MediaItem> _shuffled;
public MaskedShuffledMediaCollectionEnumerator(
IList<GroupedMediaItem> mediaItems,
IReadOnlySet<int> maskedMediaItemIds,
CollectionEnumeratorState state,
CancellationToken cancellationToken)
{
_mediaItemCount = mediaItems.Sum(i => 1 + Optional(i.Additional).Flatten().Count());
_mediaItems = mediaItems;
_cancellationToken = cancellationToken;
if (state.Index >= _mediaItems.Count)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new CloneableRandom(state.Seed);
// remove masked items from initial shuffle
var filtered = _mediaItems.Filter(mi => !maskedMediaItemIds.Contains(mi.First.Id)).ToList();
foreach (GroupedMediaItem group in filtered)
{
group.Additional.RemoveAll(mi => maskedMediaItemIds.Contains(mi.Id));
}
_shuffled = Shuffle(filtered, _random);
_lazyMinimumDuration =
new Lazy<Option<TimeSpan>>(
() => _shuffled.Bind(i => i.GetNonZeroDuration()).OrderBy(identity).HeadOrNone());
State = state;
}
public void ResetState(CollectionEnumeratorState state)
{
// only re-shuffle if needed
if (State.Seed != state.Seed)
{
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_mediaItems, _random);
}
State.Index = state.Index;
}
public CollectionEnumeratorState State { get; }
public Option<MediaItem> Current => _shuffled.Any() ? _shuffled[State.Index % _mediaItemCount] : None;
public void MoveNext()
{
if ((State.Index + 1) % _mediaItemCount == 0)
{
Option<MediaItem> tail = Current;
State.Index = 0;
do
{
State.Seed = _random.Next();
_random = new CloneableRandom(State.Seed);
_shuffled = Shuffle(_mediaItems, _random);
} while (!_cancellationToken.IsCancellationRequested && _mediaItems.Count > 1 &&
Current.Map(x => x.Id) == tail.Map(x => x.Id));
}
else
{
State.Index++;
}
State.Index %= _mediaItemCount;
}
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
public int Count => _shuffled.Count;
private IList<MediaItem> Shuffle(IEnumerable<GroupedMediaItem> list, CloneableRandom random)
{
GroupedMediaItem[] copy = list.ToArray();
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
}
}