mirror of https://github.com/ErsatzTV/ErsatzTV.git
39 changed files with 15380 additions and 87 deletions
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Interfaces.Search; |
||||
|
||||
public interface ISearchTargets |
||||
{ |
||||
event EventHandler OnSearchTargetsChanged; |
||||
|
||||
void SearchTargetsChanged(); |
||||
} |
||||
@ -0,0 +1,152 @@
@@ -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 @@
@@ -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); |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,40 @@
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
#nullable disable |
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations |
||||
{ |
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutHistorySeed : Migration |
||||
{ |
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "PlaybackOrder", |
||||
table: "PlayoutHistory", |
||||
type: "int", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
|
||||
migrationBuilder.AddColumn<int>( |
||||
name: "Seed", |
||||
table: "PlayoutHistory", |
||||
type: "int", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
} |
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropColumn( |
||||
name: "PlaybackOrder", |
||||
table: "PlayoutHistory"); |
||||
|
||||
migrationBuilder.DropColumn( |
||||
name: "Seed", |
||||
table: "PlayoutHistory"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
#nullable disable |
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||
{ |
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutHistoryPlaybackOrder : Migration |
||||
{ |
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "PlaybackOrder", |
||||
table: "PlayoutHistory", |
||||
type: "INTEGER", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
} |
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropColumn( |
||||
name: "PlaybackOrder", |
||||
table: "PlayoutHistory"); |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations; |
||||
|
||||
#nullable disable |
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations |
||||
{ |
||||
/// <inheritdoc />
|
||||
public partial class Add_PlayoutHistorySeed : Migration |
||||
{ |
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.AddColumn<int>( |
||||
name: "Seed", |
||||
table: "PlayoutHistory", |
||||
type: "INTEGER", |
||||
nullable: false, |
||||
defaultValue: 0); |
||||
} |
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder) |
||||
{ |
||||
migrationBuilder.DropColumn( |
||||
name: "Seed", |
||||
table: "PlayoutHistory"); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Search; |
||||
|
||||
public class SearchTargets : ISearchTargets |
||||
{ |
||||
public event EventHandler OnSearchTargetsChanged; |
||||
|
||||
public void SearchTargetsChanged() |
||||
{ |
||||
OnSearchTargetsChanged?.Invoke(this, EventArgs.Empty); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue