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. @@ -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/).
## [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
- Skip zero duration items when building a playout, rather than aborting the playout build
### 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
### Added

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

@ -8,5 +8,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -8,5 +8,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
public record CreateProgramSchedule(
string Name,
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 @@ -34,13 +34,18 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
private Task<Validation<BaseError, ProgramSchedule>> Validate(CreateProgramSchedule request) =>
ValidateName(request)
.MapT(
name => new ProgramSchedule
name =>
{
Name = name,
MediaCollectionPlaybackOrder = request.MediaCollectionPlaybackOrder,
KeepMultiPartEpisodesTogether =
bool keepMultiPartEpisodesTogether =
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)

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

@ -10,5 +10,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -10,5 +10,6 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
int ProgramScheduleId,
string Name,
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 @@ -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
bool needToRebuildPlayout =
programSchedule.MediaCollectionPlaybackOrder != update.MediaCollectionPlaybackOrder ||
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether;
programSchedule.KeepMultiPartEpisodesTogether != update.KeepMultiPartEpisodesTogether ||
programSchedule.TreatCollectionsAsShows != update.TreatCollectionsAsShows;
programSchedule.Name = update.Name;
programSchedule.MediaCollectionPlaybackOrder = update.MediaCollectionPlaybackOrder;
programSchedule.KeepMultiPartEpisodesTogether =
update.MediaCollectionPlaybackOrder == PlaybackOrder.Shuffle &&
update.KeepMultiPartEpisodesTogether;
programSchedule.TreatCollectionsAsShows = programSchedule.KeepMultiPartEpisodesTogether &&
update.TreatCollectionsAsShows;
await _programScheduleRepository.Update(programSchedule);
if (needToRebuildPlayout)

3
ErsatzTV.Application/ProgramSchedules/Mapper.cs

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

3
ErsatzTV.Application/ProgramSchedules/ProgramScheduleViewModel.cs

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

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

@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -24,7 +24,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@ -32,6 +32,28 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -32,6 +32,28 @@ namespace ErsatzTV.Core.Tests.Scheduling
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]
[TestCase("Episode 1 (1)", "Episode 2 (2)", "Episode 3")]
[TestCase("Episode 1 (1) - More", "Episode 2 (2) - Title", "Episode 3 - After")]
@ -45,7 +67,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -45,7 +67,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(three, 1, 1, 3)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -72,7 +94,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -72,7 +94,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(five, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -94,7 +116,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -94,7 +116,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(2);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[1]);
@ -115,7 +137,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -115,7 +137,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 4)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@ -123,6 +145,28 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -123,6 +145,28 @@ namespace ErsatzTV.Core.Tests.Scheduling
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]
[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")]
@ -137,7 +181,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -137,7 +181,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(4);
ShouldHaveOneItem(result, mediaItems[0]);
@ -160,7 +204,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -160,7 +204,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
NamedEpisode(four, 1, 1, 5)
};
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems);
List<GroupedMediaItem> result = MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, false);
result.Count.Should().Be(3);
ShouldHaveOneItem(result, mediaItems[0]);
@ -186,7 +230,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -186,7 +230,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
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);
ShouldHaveTwoItems(result, mediaItems[0], mediaItems[2]);
@ -194,6 +238,31 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -194,6 +238,31 @@ namespace ErsatzTV.Core.Tests.Scheduling
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(
string title,
int showId,
@ -225,5 +294,14 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -225,5 +294,14 @@ namespace ErsatzTV.Core.Tests.Scheduling
MediaItem additional) =>
result.Filter(g => g.First == first && Optional(g.Additional).Flatten().HeadOrNone() == Some(additional))
.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 @@ -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)
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>();
for (var i = 1; i <= 1000; i++)
@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -50,7 +50,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@ -70,7 +70,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -70,7 +70,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
var state = new CollectionEnumeratorState();
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false);
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
var list = new List<int>();
for (var i = 1; i <= 10; i++)
@ -90,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -90,7 +90,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
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++)
{
@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -105,7 +105,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
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++)
{
@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -123,7 +123,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
List<MediaItem> contents = Episodes(10);
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.Seed.Should().NotBe(MagicSeed);

1
ErsatzTV.Core/Domain/ProgramSchedule.cs

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

168
ErsatzTV.Core/Scheduling/MultiPartEpisodeGrouper.cs

@ -3,21 +3,33 @@ using System.Collections.Generic; @@ -3,21 +3,33 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using ErsatzTV.Core.Domain;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Scheduling
{
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 showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
// var showIds = episodes.Map(e => e.Season.ShowId).Distinct().ToList();
var groups = new List<GroupedMediaItem>();
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;
@ -33,13 +45,16 @@ namespace ErsatzTV.Core.Scheduling @@ -33,13 +45,16 @@ namespace ErsatzTV.Core.Scheduling
groups.Add(new GroupedMediaItem(item, null));
}
foreach (Episode episode in episodes.Filter(e => e.Season.ShowId == showId)
.OrderBy(identity, new ChronologicalMediaComparer()))
IEnumerable<Episode> sortedEpisodes = showId.Match(
id => episodes.Filter(e => e.Season.ShowId == id),
() => episodes).OrderBy(identity, new ChronologicalMediaComparer());
foreach (Episode episode in sortedEpisodes)
{
string numberString = FindPartNumber(episode);
if (numberString != null)
Option<int> maybeNumber = FindPartNumber(episode);
if (maybeNumber.IsSome)
{
var number = int.Parse(numberString);
int number = maybeNumber.ValueUnsafe();
if (number <= lastNumber && group != null)
{
groups.Add(group);
@ -47,28 +62,36 @@ namespace ErsatzTV.Core.Scheduling @@ -47,28 +62,36 @@ namespace ErsatzTV.Core.Scheduling
lastNumber = 0;
}
if (number == lastNumber + 1)
if (number > lastNumber)
{
if (lastNumber == 0)
{
// start a new group
group = new GroupedMediaItem(episode, null);
lastNumber = number;
}
else if (group != null)
else if (number == lastNumber + 1)
{
// add to current group
List<MediaItem> additional = group.Additional ?? new List<MediaItem>();
additional.Add(episode);
group = group with { Additional = additional };
if (group != null)
{
// add to current group
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
{
// this should never happen
throw new InvalidOperationException(
$"Bad shuffle state; unexpected number {number} after {lastNumber} with no existing group");
AddUngrouped(episode);
}
lastNumber = number;
}
else
{
@ -96,18 +119,37 @@ namespace ErsatzTV.Core.Scheduling @@ -96,18 +119,37 @@ namespace ErsatzTV.Core.Scheduling
return groups;
}
private static string FindPartNumber(Episode e)
private static Option<int> FindPartNumber(Episode e)
{
const string PATTERN = @"^.*\((\d+)\)( - .*)?$";
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+)$";
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)
@ -125,5 +167,85 @@ namespace ErsatzTV.Core.Scheduling @@ -125,5 +167,85 @@ namespace ErsatzTV.Core.Scheduling
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 @@ -506,7 +506,8 @@ namespace ErsatzTV.Core.Scheduling
return new ShuffledMediaCollectionEnumerator(
mediaItems,
state,
playout.ProgramSchedule.KeepMultiPartEpisodesTogether);
playout.ProgramSchedule.KeepMultiPartEpisodesTogether,
playout.ProgramSchedule.TreatCollectionsAsShows);
default:
// TODO: handle this error case differently?
return new RandomizedMediaCollectionEnumerator(mediaItems, state);

5
ErsatzTV.Core/Scheduling/ShuffledMediaCollectionEnumerator.cs

@ -18,12 +18,13 @@ namespace ErsatzTV.Core.Scheduling @@ -18,12 +18,13 @@ namespace ErsatzTV.Core.Scheduling
public ShuffledMediaCollectionEnumerator(
IList<MediaItem> mediaItems,
CollectionEnumeratorState state,
bool keepMultiPartEpisodesTogether)
bool keepMultiPartEpisodesTogether,
bool treatCollectionsAsShows)
{
_mediaItemCount = mediaItems.Count;
_mediaItems = keepMultiPartEpisodesTogether
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems)
? MultiPartEpisodeGrouper.GroupMediaItems(mediaItems, treatCollectionsAsShows)
: mediaItems.Map(mi => new GroupedMediaItem(mi, null)).ToList();
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 @@ @@ -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 @@ -1056,6 +1056,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("TreatCollectionsAsShows")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")

9
ErsatzTV/Pages/ScheduleEditor.razor

@ -28,6 +28,14 @@ @@ -28,6 +28,14 @@
Disabled="@(_model.MediaCollectionPlaybackOrder != PlaybackOrder.Shuffle)"
For="@(() => _model.KeepMultiPartEpisodesTogether)"/>
</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>
<MudCardActions>
<MudButton ButtonType="ButtonType.Submit" Variant="Variant.Filled" Color="Color.Primary">
@ -60,6 +68,7 @@ @@ -60,6 +68,7 @@
_model.Name = viewModel.Name;
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
},
() => _navigationManager.NavigateTo("404"));
}

5
ErsatzTV/ViewModels/ProgramScheduleEditViewModel.cs

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

Loading…
Cancel
Save