Browse Source

add multi-episode shuffle playout order (#987)

pull/988/head
Jason Dove 3 years ago committed by GitHub
parent
commit
f5aa2fcac8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 10
      CHANGELOG.md
  2. 1
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  3. 74
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  4. 4
      ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs
  5. 3
      ErsatzTV.Core/Domain/PlaybackOrder.cs
  6. 5
      ErsatzTV.Core/FileSystemLayout.cs
  7. 11
      ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs
  8. 50
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  9. 2
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  10. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  11. 199
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs
  12. 23
      ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs
  13. 1
      ErsatzTV/ErsatzTV.csproj
  14. 6
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  15. 13
      ErsatzTV/Resources/Scripts/_threePartEpisodes.lua
  16. 20
      ErsatzTV/Services/RunOnce/ResourceExtractorService.cs
  17. 13
      ErsatzTV/Startup.cs

10
CHANGELOG.md

@ -26,6 +26,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -26,6 +26,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- `all_artists`: a list of additional artists from the music video's sidecar NFO metadata file
- `duration`: the timespan duration of the music video, which can be used to calculate timing of additional subtitles
- `stream_seek`: the timespan that ffmpeg will seek into the media item before beginning playback
- Add `Multi-Episode Shuffle` playout order for `Television Show` schedule items
- The purpose of this playout order is to improve randomization for shows that normally have intro, multiple episodes, and outro
- This playout order requires splitting the parts into individual files (e.g. splitting `s01e01-03.mkv` into `s01e01.mkv`, `s01e02.mkv` and `s01e03.mkv`)
- This playout order requires a lua script in the config subfolder `scripts/multi-episode-shuffle`
- The lua script should be named for the television show's guid, e.g. `tvdb_12345.lua` or `imdb_tt123456789.lua`
- The script defines the number of parts that each un-split file typically contains
- The script also defines a function to map each episode to a part number (or no part number i.e. `nil` if an episode has not been split)
- All groups of part numbers (i.e. all part 1s, all part 2s) will be shuffled
- The playout order will then schedule a random part 1 followed by a random part 2, etc
- Un-split (`nil`) episodes will be randomly placed between re-combined parts (e.g. part1, part2, part3, un-split, part1, part2, part3)
### Changed
- No longer place watermarks within content by default (e.g. within 4:3 content padded to a 16:9 resolution)

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

@ -55,6 +55,7 @@ public abstract class ProgramScheduleItemCommandBase @@ -55,6 +55,7 @@ public abstract class ProgramScheduleItemCommandBase
{
case PlaybackOrder.Chronological:
case PlaybackOrder.Random:
case PlaybackOrder.MultiEpisodeShuffle:
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
case PlaybackOrder.Shuffle:
case PlaybackOrder.ShuffleInOrder:

74
ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs

@ -1,7 +1,9 @@ @@ -1,7 +1,9 @@
using Destructurama;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Filler;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Core.Tests.Fakes;
using FluentAssertions;
@ -547,11 +549,15 @@ public class PlayoutBuilderTests @@ -547,11 +549,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -639,11 +645,15 @@ public class PlayoutBuilderTests @@ -639,11 +645,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -779,11 +789,15 @@ public class PlayoutBuilderTests @@ -779,11 +789,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -877,11 +891,15 @@ public class PlayoutBuilderTests @@ -877,11 +891,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -984,11 +1002,15 @@ public class PlayoutBuilderTests @@ -984,11 +1002,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1084,11 +1106,15 @@ public class PlayoutBuilderTests @@ -1084,11 +1106,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1188,11 +1214,15 @@ public class PlayoutBuilderTests @@ -1188,11 +1214,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1297,11 +1327,15 @@ public class PlayoutBuilderTests @@ -1297,11 +1327,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1395,11 +1429,15 @@ public class PlayoutBuilderTests @@ -1395,11 +1429,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1504,11 +1542,15 @@ public class PlayoutBuilderTests @@ -1504,11 +1542,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1624,11 +1666,15 @@ public class PlayoutBuilderTests @@ -1624,11 +1666,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1736,11 +1782,15 @@ public class PlayoutBuilderTests @@ -1736,11 +1782,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -1808,11 +1858,15 @@ public class PlayoutBuilderTests @@ -1808,11 +1858,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -2017,11 +2071,15 @@ public class PlayoutBuilderTests @@ -2017,11 +2071,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(24);
@ -2385,11 +2443,15 @@ public class PlayoutBuilderTests @@ -2385,11 +2443,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -2492,11 +2554,15 @@ public class PlayoutBuilderTests @@ -2492,11 +2554,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -2599,11 +2665,15 @@ public class PlayoutBuilderTests @@ -2599,11 +2665,15 @@ public class PlayoutBuilderTests
var configRepo = new Mock<IConfigElementRepository>();
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
fakeRepository,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
DateTimeOffset start = HoursAfterMidnight(0);
@ -2679,11 +2749,15 @@ public class PlayoutBuilderTests @@ -2679,11 +2749,15 @@ public class PlayoutBuilderTests
var collectionRepo = new FakeMediaCollectionRepository(Map((mediaCollection.Id, mediaItems)));
var televisionRepo = new FakeTelevisionRepository();
var artistRepo = new Mock<IArtistRepository>();
var factory = new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>();
var localFileSystem = new Mock<ILocalFileSystem>();
var builder = new PlayoutBuilder(
configRepo.Object,
collectionRepo,
televisionRepo,
artistRepo.Object,
factory.Object,
localFileSystem.Object,
_logger);
var items = new List<ProgramScheduleItem> { Flood(mediaCollection, playbackOrder) };

4
ErsatzTV.Core.Tests/Scheduling/ScheduleIntegrationTests.cs

@ -1,6 +1,8 @@ @@ -1,6 +1,8 @@
using Dapper;
using Destructurama;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Scheduling;
using ErsatzTV.Infrastructure.Data;
@ -153,6 +155,8 @@ public class ScheduleIntegrationTests @@ -153,6 +155,8 @@ public class ScheduleIntegrationTests
new MediaCollectionRepository(new Mock<ISearchIndex>().Object, factory),
new TelevisionRepository(factory),
new ArtistRepository(factory),
new Mock<IMultiEpisodeShuffleCollectionEnumeratorFactory>().Object,
new Mock<ILocalFileSystem>().Object,
provider.GetRequiredService<ILogger<PlayoutBuilder>>());
for (var i = 0; i <= (24 * 4); i++)

3
ErsatzTV.Core/Domain/PlaybackOrder.cs

@ -5,5 +5,6 @@ public enum PlaybackOrder @@ -5,5 +5,6 @@ public enum PlaybackOrder
Chronological = 1,
Random = 2,
Shuffle = 3,
ShuffleInOrder = 4
ShuffleInOrder = 4,
MultiEpisodeShuffle = 5
}

5
ErsatzTV.Core/FileSystemLayout.cs

@ -50,4 +50,9 @@ public static class FileSystemLayout @@ -50,4 +50,9 @@ public static class FileSystemLayout
public static readonly string MusicVideoCreditsTemplatesFolder =
Path.Combine(TemplatesFolder, "music-video-credits");
public static readonly string ScriptsFolder = Path.Combine(AppDataFolder, "scripts");
public static readonly string MultiEpisodeShuffleTemplatesFolder =
Path.Combine(ScriptsFolder, "multi-episode-shuffle");
}

11
ErsatzTV.Core/Interfaces/Scheduling/IMultiEpisodeShuffleCollectionEnumeratorFactory.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Scheduling;
public interface IMultiEpisodeShuffleCollectionEnumeratorFactory
{
IMediaCollectionEnumerator Create(
string luaTemplatePath,
IList<MediaItem> mediaItems,
CollectionEnumeratorState state);
}

50
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Extensions;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt.UnsafeValueAccess;
@ -14,6 +15,8 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -14,6 +15,8 @@ public class PlayoutBuilder : IPlayoutBuilder
{
private static readonly Random Random = new();
private readonly IArtistRepository _artistRepository;
private readonly IMultiEpisodeShuffleCollectionEnumeratorFactory _multiEpisodeFactory;
private readonly ILocalFileSystem _localFileSystem;
private readonly IConfigElementRepository _configElementRepository;
private readonly ILogger<PlayoutBuilder> _logger;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
@ -24,12 +27,16 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -24,12 +27,16 @@ public class PlayoutBuilder : IPlayoutBuilder
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository,
IArtistRepository artistRepository,
IMultiEpisodeShuffleCollectionEnumeratorFactory multiEpisodeFactory,
ILocalFileSystem localFileSystem,
ILogger<PlayoutBuilder> logger)
{
_configElementRepository = configElementRepository;
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_artistRepository = artistRepository;
_multiEpisodeFactory = multiEpisodeFactory;
_localFileSystem = localFileSystem;
_logger = logger;
}
@ -746,15 +753,50 @@ public class PlayoutBuilder : IPlayoutBuilder @@ -746,15 +753,50 @@ public class PlayoutBuilder : IPlayoutBuilder
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.Random:
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
state);
case PlaybackOrder.ShuffleInOrder:
return new ShuffleInOrderCollectionEnumerator(
await GetCollectionItemsForShuffleInOrder(collectionKey),
state,
playout.ProgramSchedule.RandomStartPoint);
case PlaybackOrder.MultiEpisodeShuffle when
collectionKey.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow &&
collectionKey.MediaItemId.HasValue:
foreach (Show show in await _televisionRepository.GetShow(collectionKey.MediaItemId.Value))
{
foreach (MetadataGuid guid in show.ShowMetadata.Map(sm => sm.Guids).Flatten())
{
string luaTemplatePath = Path.ChangeExtension(
Path.Combine(
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
guid.Guid.Replace("://", "_")),
"lua");
_logger.LogDebug("Checking for lua template at {Path}", luaTemplatePath);
if (_localFileSystem.FileExists(luaTemplatePath))
{
_logger.LogDebug("Found lua template at {Path}", luaTemplatePath);
try
{
return _multiEpisodeFactory.Create(luaTemplatePath, mediaItems, state);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Failed to initialize multi-episode shuffle; falling back to normal shuffle");
}
}
}
}
// fall back to shuffle if show or template cannot be found
goto case PlaybackOrder.Shuffle;
// fall back to shuffle when television show isn't selected
case PlaybackOrder.MultiEpisodeShuffle:
case PlaybackOrder.Shuffle:
return new ShuffledMediaCollectionEnumerator(
await GetGroupedMediaItemsForShuffle(playout, mediaItems, collectionKey),
state);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);

2
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -67,6 +67,8 @@ public class TelevisionRepository : ITelevisionRepository @@ -67,6 +67,8 @@ public class TelevisionRepository : ITelevisionRepository
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Actors)
.ThenInclude(a => a.Artwork)
.Include(s => s.ShowMetadata)
.ThenInclude(sm => sm.Guids)
.OrderBy(s => s.Id)
.SingleOrDefaultAsync()
.Map(Optional);

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -24,6 +24,7 @@ @@ -24,6 +24,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NLua" Version="1.6.0" />
<PackageReference Include="Refit" Version="6.3.2" />
<PackageReference Include="Refit.Newtonsoft.Json" Version="6.3.2" />
<PackageReference Include="Refit.Xml" Version="6.3.2" />

199
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumerator.cs

@ -0,0 +1,199 @@ @@ -0,0 +1,199 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using ErsatzTV.Core.Scheduling;
using Microsoft.Extensions.Logging;
using NLua;
namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumerator : IMediaCollectionEnumerator
{
private readonly ILogger _logger;
private readonly int _mediaItemCount;
private readonly Dictionary<int, List<MediaItem>> _mediaItemGroups;
private readonly List<MediaItem> _ungrouped;
private CloneableRandom _random;
private IList<MediaItem> _shuffled;
public MultiEpisodeShuffleCollectionEnumerator(
IList<MediaItem> mediaItems,
CollectionEnumeratorState state,
string templateFile,
ILogger logger)
{
_logger = logger;
using var lua = new Lua();
lua.DoFile(templateFile);
var numGroups = (int)(double)lua["numParts"];
_mediaItemGroups = new Dictionary<int, List<MediaItem>>();
for (var i = 1; i <= numGroups; i++)
{
_mediaItemGroups.Add(i, new List<MediaItem>());
}
_ungrouped = new List<MediaItem>();
_mediaItemCount = mediaItems.Count;
var groupForEpisode = (LuaFunction)lua["partNumberForEpisode"];
IList<Episode> validEpisodes = mediaItems
.OfType<Episode>()
.Filter(e => e.Season is not null && e.EpisodeMetadata is not null && e.EpisodeMetadata.Count == 1)
.ToList();
foreach (Episode episode in validEpisodes)
{
// prep lua params
int seasonNumber = episode.Season.SeasonNumber;
int episodeNumber = episode.EpisodeMetadata[0].EpisodeNumber;
// call the lua fn
object[] result = groupForEpisode.Call(seasonNumber, episodeNumber);
// if we get a group number back, use it
if (result[0] is long groupNumber)
{
_mediaItemGroups[(int)groupNumber].Add(episode);
}
else
{
_ungrouped.Add(episode);
}
}
// add everything else
_ungrouped.AddRange(mediaItems.Except(validEpisodes));
if (state.Index >= _mediaItemCount)
{
state.Index = 0;
state.Seed = new Random(state.Seed).Next();
}
_random = new CloneableRandom(state.Seed);
_shuffled = Shuffle(_random);
State = new CollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
{
MoveNext();
}
}
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(_random);
} while (_mediaItemCount > 1 && Current == tail);
}
else
{
State.Index++;
}
State.Index %= _mediaItemCount;
}
public Option<MediaItem> Peek(int offset)
{
if (offset == 0)
{
return Current;
}
if ((State.Index + offset) % _mediaItemCount == 0)
{
IList<MediaItem> shuffled;
Option<MediaItem> tail = Current;
// clone the random
CloneableRandom randomCopy = _random.Clone();
do
{
int newSeed = randomCopy.Next();
randomCopy = new CloneableRandom(newSeed);
shuffled = Shuffle(randomCopy);
} while (_mediaItemCount > 1 && shuffled[0] == tail);
return shuffled.Any() ? shuffled[0] : None;
}
return _shuffled.Any() ? _shuffled[(State.Index + offset) % _mediaItemCount] : None;
}
private IList<MediaItem> Shuffle(CloneableRandom random)
{
int maxGroupNumber = _mediaItemGroups.Max(a => a.Key);
var shuffledGroups = new List<IList<MediaItem>>();
for (var i = 1; i <= maxGroupNumber; i++)
{
shuffledGroups.Add(Shuffle(_mediaItemGroups[i], random));
}
int minItems = shuffledGroups.Min(g => g.Count);
if (shuffledGroups.Any(g => g.Count != minItems))
{
_logger.LogError("Multi Episode Groups are different sizes; shuffle will not perform correctly!");
}
// convert shuffled "groups" into groups that can be used for scheduling
var copy = new GroupedMediaItem[minItems + _ungrouped.Count];
for (var i = 0; i < minItems; i++)
{
var group = new GroupedMediaItem(shuffledGroups[0][i], null);
for (var j = 1; j < shuffledGroups.Count; j++)
{
group.Additional.Add(shuffledGroups[j][i]);
}
copy[i] = group;
}
// convert all ungrouped into groups that can be used for scheduling
for (var i = 0; i < _ungrouped.Count; i++)
{
MediaItem ungrouped = _ungrouped[i];
copy[minItems + i] = new GroupedMediaItem(ungrouped, null);
}
// perform shuffle
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
// flatten
return GroupedMediaItem.FlattenGroups(copy, _mediaItemCount);
}
private static IList<MediaItem> Shuffle(IEnumerable<MediaItem> mediaItems, CloneableRandom random)
{
MediaItem[] copy = mediaItems.ToArray();
int n = copy.Length;
while (n > 1)
{
n--;
int k = random.Next(n + 1);
(copy[k], copy[n]) = (copy[n], copy[k]);
}
return copy;
}
}

23
ErsatzTV.Infrastructure/Scheduling/MultiEpisodeShuffleCollectionEnumeratorFactory.cs

@ -0,0 +1,23 @@ @@ -0,0 +1,23 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Scheduling;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Infrastructure.Scheduling;
public class MultiEpisodeShuffleCollectionEnumeratorFactory
: IMultiEpisodeShuffleCollectionEnumeratorFactory
{
private readonly ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> _logger;
public MultiEpisodeShuffleCollectionEnumeratorFactory(
ILogger<MultiEpisodeShuffleCollectionEnumeratorFactory> logger)
{
_logger = logger;
}
public IMediaCollectionEnumerator Create(
string luaTemplatePath,
IList<MediaItem> mediaItems,
CollectionEnumeratorState state) =>
new MultiEpisodeShuffleCollectionEnumerator(mediaItems, state, luaTemplatePath, _logger);
}

1
ErsatzTV/ErsatzTV.csproj

@ -98,6 +98,7 @@ @@ -98,6 +98,7 @@
<ItemGroup>
<EmbeddedResource Include="Resources\background.png" />
<EmbeddedResource Include="Resources\Scripts\_threePartEpisodes.lua" />
<EmbeddedResource Include="Resources\song_background_1.png" />
<EmbeddedResource Include="Resources\song_background_2.png" />
<EmbeddedResource Include="Resources\song_background_3.png" />

6
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -195,6 +195,12 @@ @@ -195,6 +195,12 @@
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.ShuffleInOrder">Shuffle In Order</MudSelectItem>
break;
case ProgramScheduleItemCollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>

13
ErsatzTV/Resources/Scripts/_threePartEpisodes.lua

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
-- the number of parts that each un-split file typically contains
numParts = 3
-- return the part number for the given season number and episode number
function partNumberForEpisode(seasonNumber, episodeNumber)
local mod = episodeNumber % 3
if mod == 0
then
return 3
else
return mod
end
end

20
ErsatzTV/Services/RunOnce/ResourceExtractorService.cs

@ -28,6 +28,12 @@ public class ResourceExtractorService : IHostedService @@ -28,6 +28,12 @@ public class ResourceExtractorService : IHostedService
"_default.ass.sbntxt",
FileSystemLayout.MusicVideoCreditsTemplatesFolder,
cancellationToken);
await ExtractScriptResource(
assembly,
"_threePartEpisodes.lua",
FileSystemLayout.MultiEpisodeShuffleTemplatesFolder,
cancellationToken);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@ -71,4 +77,18 @@ public class ResourceExtractorService : IHostedService @@ -71,4 +77,18 @@ public class ResourceExtractorService : IHostedService
await resource.CopyToAsync(fs, cancellationToken);
}
}
private static async Task ExtractScriptResource(
Assembly assembly,
string name,
string targetFolder,
CancellationToken cancellationToken)
{
await using Stream resource = assembly.GetManifestResourceStream($"ErsatzTV.Resources.Scripts.{name}");
if (resource != null)
{
await using FileStream fs = File.Create(Path.Combine(targetFolder, name));
await resource.CopyToAsync(fs, cancellationToken);
}
}
}

13
ErsatzTV/Startup.cs

@ -48,6 +48,7 @@ using ErsatzTV.Infrastructure.Jellyfin; @@ -48,6 +48,7 @@ using ErsatzTV.Infrastructure.Jellyfin;
using ErsatzTV.Infrastructure.Locking;
using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Infrastructure.Runtime;
using ErsatzTV.Infrastructure.Scheduling;
using ErsatzTV.Infrastructure.Search;
using ErsatzTV.Infrastructure.Trakt;
using ErsatzTV.Serialization;
@ -199,6 +200,16 @@ public class Startup @@ -199,6 +200,16 @@ public class Startup
Directory.CreateDirectory(FileSystemLayout.MusicVideoCreditsTemplatesFolder);
}
if (!Directory.Exists(FileSystemLayout.ScriptsFolder))
{
Directory.CreateDirectory(FileSystemLayout.ScriptsFolder);
}
if (!Directory.Exists(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder))
{
Directory.CreateDirectory(FileSystemLayout.MultiEpisodeShuffleTemplatesFolder);
}
Log.Logger.Information("Database is at {DatabasePath}", FileSystemLayout.DatabasePath);
// until we add a setting for a file-specific scheme://host:port to access
@ -410,6 +421,8 @@ public class Startup @@ -410,6 +421,8 @@ public class Startup
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();
services.AddScoped<IHardwareCapabilitiesFactory, HardwareCapabilitiesFactory>();
services.AddScoped<IMultiEpisodeShuffleCollectionEnumeratorFactory,
MultiEpisodeShuffleCollectionEnumeratorFactory>();
services.AddScoped<IFFmpegProcessService, FFmpegLibraryProcessService>();
services.AddScoped<FFmpegProcessService>();

Loading…
Cancel
Save