Browse Source

keep crossover episodes together (#237)

pull/238/head
Jason Dove 4 years ago committed by GitHub
parent
commit
c70f153241
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      CHANGELOG.md
  2. 3
      ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs
  3. 15
      ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs
  4. 3
      ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs
  5. 5
      ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs
  6. 3
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  7. 3
      ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs
  8. 94
      ErsatzTV.Core.Tests/Scheduling/MultiPartEpisodeGrouperTests.cs
  9. 12
      ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
  10. 1
      ErsatzTV.Core/Domain/ProgramSchedule.cs
  11. 168
      ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs
  12. 3
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  13. 5
      ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs
  14. 2889
      ErsatzTV.Infrastructure/Migrations/20210531153325_Add_ProgramScheduleTreatCollectionsAsShows.Designer.cs
  15. 24
      ErsatzTV.Infrastructure/Migrations/20210531153325_Add_ProgramScheduleTreatCollectionsAsShows.cs
  16. 3
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  17. 9
      ErsatzTV/Pages/ScheduleEditor.razor
  18. 5
      ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs

6
CHANGELOG.md

@ -4,12 +4,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
## [Unreleased] ## [Unreleased]
### Added
- Support roman numerals and english integer names for multi-part episode grouping
- Add option to treat entire collection as a single show with multi-part episode grouping
- This is useful for multi-part episodes that span multiple shows (crossovers)
### Changed ### Changed
- Skip zero duration items when building a playout, rather than aborting the playout build - Skip zero duration items when building a playout, rather than aborting the playout build
### Fixed ### Fixed
- Fix edge case where a playout rebuild would get stuck and block all other playouts and library scans - Fix edge case where a playout rebuild would get stuck and block all other playouts and local library scans
## [0.0.41-prealpha] - 2021-05-30 ## [0.0.41-prealpha] - 2021-05-30
### Added ### Added

3
ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramSchedule.cs

@ -8,5 +8,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
public record CreateProgramSchedule( public record CreateProgramSchedule(
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder, PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>; bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
} }

15
ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs

@ -34,13 +34,18 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) => private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
ValidateName(request) ValidateName(request)
.MapT( .MapT(
name => new ProgramSchedule name =>
{ {
Name = name, bool keepMultiPartEpisodesTogether =
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether =
request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle && request.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
request.KeepMultiPartEpisodesTogether request.KeepMultiPartEpisodesTogether;
return new ProgramSchedule
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether = keepMultiPartEpisodesTogether,
TreatCollectionsAsShows = keepMultiPartEpisodesTogether && request.TreatCollectionsAsShows
};
}); });
private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule) private async Task<Validation<BaseError, string>> ValidateName(CreateProgramSchedule createProgramSchedule)

3
ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramSchedule.cs

@ -10,5 +10,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int ProgramScheduleId, int ProgramScheduleId,
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder, PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether) : IRequest<Either<BaseError, ProgramScheduleViewModel>>; bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows) : IRequest<Either<BaseError, ProgramScheduleViewModel>>;
} }

5
ErsatzTV.Application/ProgramSchedules/Commands/UpdateProgramScheduleHandler.cs

@ -40,13 +40,16 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
// we need to rebuild playouts if the playback order or keep multi-episodes has been modified // we need to rebuild playouts if the playback order or keep multi-episodes has been modified
bool needToRebuildPlayout = bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder || programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether; programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != update.TreatCollectionsAsShows;
programSchedule.Name = update.Name; programSchedule.Name = update.Name;
programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder; programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether = programSchedule.KeepMultiPartEpisodesTogether =
update.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle && update.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
update.KeepMultiPartEpisodesTogether; update.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
update.TreatCollectionsAsShows;
await _programScheduleRepository.Update(programSchedule); await _programScheduleRepository.Update(programSchedule);
if (needToRebuildPlayout) if (needToRebuildPlayout)

3
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -10,7 +10,8 @@ namespace ErsatzTV.Application.ProgramSchedules
programSchedule.Id, programSchedule.Id,
programSchedule.Name, programSchedule.Name,
programSchedule.MediaCollectionPlaybackOrder, programSchedule.MediaCollectionPlaybackOrder,
programSchedule.KeepMultiPartEpisodesTogether); programSchedule.KeepMultiPartEpisodesTogether,
programSchedule.TreatCollectionsAsShows);
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) => internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
programScheduleItem switch programScheduleItem switch

3
ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs

@ -6,5 +6,6 @@ namespace ErsatzTV.Application.ProgramSchedules
int Id, int Id,
string Name, string Name,
PlaybackOrder MediaCollectionPlaybackOrder, PlaybackOrder MediaCollectionPlaybackOrder,
bool KeepMultiPartEpisodesTogether); bool KeepMultiPartEpisodesTogether,
bool TreatCollectionsAsShows);
} }

94
ErsatzTV.Core.Tests/Scheduling/MultiPartEpisodeGrouperTests.cs

@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4) NamedEpisode(four, 1, 1, 4)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3); result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]); ShouldHaveOneItem(result, mediaItems[0]);
@ -32,6 +32,28 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveOneItem(result, mediaItems[3]); ShouldHaveOneItem(result, mediaItems[3]);
} }
[Test]
[TestCase("Episode 1 (1)", "Episode 2 - Part 2", "Episode 3")]
[TestCase("Episode 1 Part 1", "Episode 2 (2) - More", "Episode 3 - After")]
[TestCase("Episode 1 Part 1", "Episode 2 (II)", "Episode 3")]
[TestCase("Episode 1 Part (V)", "Episode 2 (VI)", "Episode 3")]
[TestCase("Episode 1 Part Three", "Episode 2 (IV)", "Episode 3")]
public void MixedNaming_Group(string one, string two, string three)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
ShouldHaveOneItem(result, mediaItems[2]);
}
[Test] [Test]
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")] [TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")] [TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
@ -45,7 +67,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(three, 1, 1, 3) NamedEpisode(three, 1, 1, 3)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2); result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]); ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -72,7 +94,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(five, 1, 1, 5) NamedEpisode(five, 1, 1, 5)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3); result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]); ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -94,7 +116,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4) NamedEpisode(four, 1, 1, 4)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2); result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]); ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -115,7 +137,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4) NamedEpisode(four, 1, 1, 4)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3); result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]); ShouldHaveOneItem(result, mediaItems[0]);
@ -123,6 +145,28 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveTwoItems(result, mediaItems[2], mediaItems[3]); ShouldHaveTwoItems(result, mediaItems[2], mediaItems[3]);
} }
[Test]
[TestCase("Episode 1", "Episode 2 (2)", "Episode 3 (3)", "Episode 4")]
[TestCase("Episode 1 - More", "Episode 2 (2) - Title", "Episode 3 (3) - After", "Episode 4 - Dash")]
[TestCase("Episode 1", "Episode 2 Part 2", "Episode 3 Part 3", "Episode 4")]
public void Part2And3_Without_Part1(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1),
NamedEpisode(two, 1, 1, 2),
NamedEpisode(three, 1, 1, 3),
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
ShouldHaveTwoItems(result, mediaItems[1], mediaItems[2]);
ShouldHaveOneItem(result, mediaItems[3]);
}
[Test] [Test]
[TestCase("Episode 1 (1)", "Episode 3 (3)", "Episode 4", "Episode 5")] [TestCase("Episode 1 (1)", "Episode 3 (3)", "Episode 4", "Episode 5")]
[TestCase("Episode 1 (1) - More", "Episode 3 (3) - Title", "Episode 4 - After", "Episode 5 - Dash")] [TestCase("Episode 1 (1) - More", "Episode 3 (3) - Title", "Episode 4 - After", "Episode 5 - Dash")]
@ -137,7 +181,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5) NamedEpisode(four, 1, 1, 5)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(4); result.Count.Should().Be(4);
ShouldHaveOneItem(result, mediaItems[0]); ShouldHaveOneItem(result, mediaItems[0]);
@ -160,7 +204,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5) NamedEpisode(four, 1, 1, 5)
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3); result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]); ShouldHaveOneItem(result, mediaItems[0]);
@ -186,7 +230,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4)) NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
}; };
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems); List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3); result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[2]); ShouldHaveTwoItems(result, mediaItems[0], mediaItems[2]);
@ -194,6 +238,31 @@ namespace ErsatzTV.Core.Tests.Scheduling
ShouldHaveOneItem(result, mediaItems[3]); ShouldHaveOneItem(result, mediaItems[3]);
} }
[Test]
[TestCase("S1 Episode 1 (1)", "S2 Episode 3 (2)", "S1 Episode 2 (3)", "S1 Episode 5")]
[TestCase(
"S1 Episode 1 (1) - More",
"S2 Episode 3 (2) - Title",
"S1 Episode 2 (3) - After",
"S1 Episode 5 - Dash")]
[TestCase("S1 Episode 1 Part 1", "S2 Episode 3 Part 2", "S1 Episode 2 Part 3", "S1 Episode 5")]
public void Mixed_Shows_Chronologically_Crossover(string one, string two, string three, string four)
{
var mediaItems = new List<MediaItem>
{
NamedEpisode(one, 1, 1, 1, new DateTime(2020, 1, 1)),
NamedEpisode(two, 2, 1, 3, new DateTime(2020, 1, 2)),
NamedEpisode(three, 1, 1, 2, new DateTime(2020, 1, 3)),
NamedEpisode(four, 1, 1, 5, new DateTime(2020, 1, 4))
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, true);
result.Count.Should().Be(2);
ShouldHaveMultipleItems(result, mediaItems[0], new List<MediaItem> { mediaItems[1], mediaItems[2] });
ShouldHaveOneItem(result, mediaItems[3]);
}
private static Episode NamedEpisode( private static Episode NamedEpisode(
string title, string title,
int showId, int showId,
@ -225,5 +294,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
MediaItem additional) => MediaItem additional) =>
result.Filter(g => g.First == first && Optional(g.Additional).Flatten().HeadOrNone() == Some(additional)) result.Filter(g => g.First == first && Optional(g.Additional).Flatten().HeadOrNone() == Some(additional))
.Should().HaveCount(1); .Should().HaveCount(1);
private static void ShouldHaveMultipleItems(
IEnumerable<GroupedMediaItem> result,
MediaItem first,
List<MediaItem> additional) =>
result.Filter(
g => g.First == first && g.Additional != null && g.Additional.Count == additional.Count &&
additional.ForAll(g.Additional.Contains))
.Should().HaveCount(1);
} }
} }

12
ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs

@ -23,7 +23,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
// normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end) // normally returns 10 5 7 4 3 6 2 8 9 1 1 (note duplicate 1 at end)
var state = new CollectionEnumeratorState { Seed = 8 }; var state = new CollectionEnumeratorState { Seed = 8 };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 1000; i++) for (var i = 1; i <= 1000; i++)
@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 10; i++) for (var i = 1; i <= 10; i++)
@ -70,7 +70,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>(); var list = new List<int>();
for (var i = 1; i <= 10; i++) for (var i = 1; i <= 10; i++)
@ -90,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState(); var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 0; i < 10; i++) for (var i = 0; i < 10; i++)
{ {
@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed }; var state = new CollectionEnumeratorState { Index = 5, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
for (var i = 6; i <= 10; i++) for (var i = 6; i <= 10; i++)
{ {
@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10); List<MediaItem> contents = Episodes(10);
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed }; var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false); var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
shuffledContent.State.Index.Should().Be(0); shuffledContent.State.Index.Should().Be(0);
shuffledContent.State.Seed.Should().NotBe(MagicSeed); shuffledContent.State.Seed.Should().NotBe(MagicSeed);

1
ErsatzTV.Core/Domain/ProgramSchedule.cs

@ -8,6 +8,7 @@ namespace ErsatzTV.Core.Domain
public string Name { get; set; } public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; } public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; } public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public List<ProgramScheduleItem> Items { get; set; } public List<ProgramScheduleItem> Items { get; set; }
public List<Playout> Playouts { get; set; } public List<Playout> Playouts { get; set; }
} }

168
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

@ -3,21 +3,33 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain; using ErsatzTV.Core.Domain;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using static LanguageExt.Prelude; using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling namespace ErsatzTV.Core.Scheduling
{ {
public static class MultiPartEpisodeGrouper public static class MultiPartEpisodeGrouper
{ {
public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems) public static List<GroupedMediaItem> GroupMediaItems(IList<MediaItem> mediaItems, bool treatCollectionsAsShows)
{ {
var episodes = mediaItems.OfType<Episode>().ToList(); var episodes = mediaItems.OfType<Episode>().ToList();
var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList(); // var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
var groups = new List<GroupedMediaItem>(); var groups = new List<GroupedMediaItem>();
GroupedMediaItem group = null; GroupedMediaItem group = null;
foreach (int showId in showIds) var showIds = new List<Option<int>>();
if (treatCollectionsAsShows)
{
showIds.Add(Option<int>.None);
}
else
{
showIds.AddRange(episodes.Map(e => Some(e.Season.ShowId)).Distinct());
}
foreach (Option<int> showId in showIds)
{ {
var lastNumber = 0; var lastNumber = 0;
@ -33,13 +45,16 @@ namespace ErsatzTV.Core.Scheduling
groups.Add(new GroupedMediaItem(item, null)); groups.Add(new GroupedMediaItem(item, null));
} }
foreach (Episode episode in episodes.Filter(e => e.Season.ShowId == showId) IEnumerable<Episode> sortedEpisodes = showId.Match(
.OrderBy(identity, new ChronologicalMediaComparer())) id => episodes.Filter(e => e.Season.ShowId == id),
() => episodes).OrderBy(identity, new ChronologicalMediaComparer());
foreach (Episode episode in sortedEpisodes)
{ {
string numberString = FindPartNumber(episode); Option<int> maybeNumber = FindPartNumber(episode);
if (numberString != null) if (maybeNumber.IsSome)
{ {
var number = int.Parse(numberString); int number = maybeNumber.ValueUnsafe();
if (number <= lastNumber && group != null) if (number <= lastNumber && group != null)
{ {
groups.Add(group); groups.Add(group);
@ -47,28 +62,36 @@ namespace ErsatzTV.Core.Scheduling
lastNumber = 0; lastNumber = 0;
} }
if (number == lastNumber + 1) if (number > lastNumber)
{ {
if (lastNumber == 0) if (lastNumber == 0)
{ {
// start a new group // start a new group
group = new GroupedMediaItem(episode, null); group = new GroupedMediaItem(episode, null);
lastNumber = number;
} }
else if (group != null) else if (number == lastNumber + 1)
{ {
// add to current group if (group != null)
List<MediaItem> additional = group.Additional ?? new List<MediaItem>(); {
additional.Add(episode); // add to current group
group = group with { Additional = additional }; List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode);
group = group with { Additional = additional };
}
else
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
}
lastNumber = number;
} }
else else
{ {
// this should never happen AddUngrouped(episode);
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
} }
lastNumber = number;
} }
else else
{ {
@ -96,18 +119,37 @@ namespace ErsatzTV.Core.Scheduling
return groups; return groups;
} }
private static string FindPartNumber(Episode e) private static Option<int> FindPartNumber(Episode e)
{ {
const string PATTERN = @"^.*\((\d+)\)( - .*)?$"; const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN); Match match = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN);
if (match.Success) if (match.Success && int.TryParse(match.Groups[1].Value, out int value1))
{ {
return match.Groups[1].Value; return value1;
} }
const string PATTERN_2 = @"^.*Part (\d+)$"; const string PATTERN_2 = @"^.*Part (\d+)$";
Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2); Match match2 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_2);
return match2.Success ? match2.Groups[1].Value : null; if (match2.Success && int.TryParse(match2.Groups[1].Value, out int value2))
{
return value2;
}
const string PATTERN_3 = @"^.*\(([MDCLXVI]+)\)( - .*)?$";
Match match3 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_3);
if (match3.Success && TryParseRoman(match3.Groups[1].Value, out int value3))
{
return value3;
}
const string PATTERN_4 = @"^.*Part (\w+)$";
Match match4 = Regex.Match(e.EpisodeMetadata.Head().Title, PATTERN_4);
if (match4.Success && TryParseEnglish(match4.Groups[1].Value, out int value4))
{
return value4;
}
return None;
} }
public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount) public static IList<MediaItem> FlattenGroups(GroupedMediaItem[] copy, int mediaItemCount)
@ -125,5 +167,85 @@ namespace ErsatzTV.Core.Scheduling
return result; return result;
} }
private static bool TryParseRoman(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "i":
output = 1;
return true;
case "ii":
output = 2;
return true;
case "iii":
output = 3;
return true;
case "iv":
output = 4;
return true;
case "v":
output = 5;
return true;
case "vi":
output = 6;
return true;
case "vii":
output = 7;
return true;
case "viii" or "iix":
output = 8;
return true;
case "ix":
output = 9;
return true;
case "x":
output = 10;
return true;
default:
output = 0;
return false;
}
}
private static bool TryParseEnglish(string input, out int output)
{
switch (input?.ToLowerInvariant())
{
case "one":
output = 1;
return true;
case "two":
output = 2;
return true;
case "three":
output = 3;
return true;
case "four":
output = 4;
return true;
case "five":
output = 5;
return true;
case "six":
output = 6;
return true;
case "seven":
output = 7;
return true;
case "eight":
output = 8;
return true;
case "nine":
output = 9;
return true;
case "ten":
output = 10;
return true;
default:
output = 0;
return false;
}
}
} }
} }

3
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -506,7 +506,8 @@ namespace ErsatzTV.Core.Scheduling
return new ShuffledMediaCollectionEnumerator( return new ShuffledMediaCollectionEnumerator(
mediaItems, mediaItems,
state, state,
playout.ProgramSchedule.KeepMultiPartEpisodesTogether); playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
playout.ProgramSchedule.TreatCollectionsAsShows);
default: default:
// TODO: handle this error case differently? // TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state); return new RandomizedMediaCollectionEnumerator(mediaItems, state);

5
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -18,12 +18,13 @@ namespace ErsatzTV.Core.Scheduling
public ShuffledMediaCollectionEnumerator( public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems, IList<MediaItem> mediaItems,
CollectionEnumeratorState state, CollectionEnumeratorState state,
bool keepMultiPartEpisodesTogether) bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
{ {
_mediaItemCount = mediaItems.Count; _mediaItemCount = mediaItems.Count;
_mediaItems = keepMultiPartEpisodesTogether _mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems) ? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList(); : mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
if (state.Index >= _mediaItems.Count) if (state.Index >= _mediaItems.Count)

2889
ErsatzTV.Infrastructure/Migrations/20210531153325_Add_ProgramScheduleTreatCollectionsAsShows.Designer.cs generated

File diff suppressed because it is too large Load Diff

24
ErsatzTV.Infrastructure/Migrations/20210531153325_Add_ProgramScheduleTreatCollectionsAsShows.cs

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_ProgramScheduleTreatCollectionsAsShows : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TreatCollectionsAsShows",
table: "ProgramSchedule");
}
}
}

3
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1056,6 +1056,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<bool>("TreatCollectionsAsShows")
.HasColumnType("INTEGER");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("Name") b.HasIndex("Name")

9
ErsatzTV/Pages/ScheduleEditor.razor

@ -28,6 +28,14 @@
Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)" Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)"
For="@(() => _model.KeepMultiPartEpisodesTogether)"/> For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
</MudElement> </MudElement>
<MudElement HtmlTag="div" Class="mt-3">
<MudTooltip Text="This is useful for multi-part crossover episodes">
<MudCheckBox Label="Treat Collections As Shows*"
@bind-Checked="@_model.TreatCollectionsAsShows"
Disabled="@(_model.KeepMultiPartEpisodesTogether == false)"
For="@(() => _model.TreatCollectionsAsShows)"/>
</MudTooltip>
</MudElement>
</MudCardContent> </MudCardContent>
<MudCardActions> <MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary"> <MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -60,6 +68,7 @@
_model.Name = viewModel.Name; _model.Name = viewModel.Name;
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder; _model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether; _model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
}, },
() => _navigationManager.NavigateTo("404")); () => _navigationManager.NavigateTo("404"));
} }

5
ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs

@ -9,11 +9,12 @@ namespace ErsatzTV.ViewModels
public string Name { get; set; } public string Name { get; set; }
public PlaybackOrder MediaCollectionPlaybackOrder { get; set; } public PlaybackOrder MediaCollectionPlaybackOrder { get; set; }
public bool KeepMultiPartEpisodesTogether { get; set; } public bool KeepMultiPartEpisodesTogether { get; set; }
public bool TreatCollectionsAsShows { get; set; }
public UpdateProgramSchedule ToUpdate() => public UpdateProgramSchedule ToUpdate() =>
new(Id, Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether); new(Id, Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
public CreateProgramSchedule ToCreate() => public CreateProgramSchedule ToCreate() =>
new(Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether); new(Name, MediaCollectionPlaybackOrder, KeepMultiPartEpisodesTogether, TreatCollectionsAsShows);
} }
} }

Loading…
Cancel
Save