Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8631a1f12 | ||
|
|
c70f153241 | ||
|
|
eee10dee22 | ||
|
|
9f575dbd94 |
+15
-2
@@ -3,7 +3,19 @@ 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
|
||||
## [Unreleased]
|
||||
|
||||
## [0.0.42-prealpha] - 2021-05-31
|
||||
### 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 local library scans
|
||||
|
||||
## [0.0.41-prealpha] - 2021-05-30
|
||||
### Added
|
||||
@@ -380,7 +392,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
||||
- Initial release to facilitate testing outside of Docker.
|
||||
|
||||
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...HEAD
|
||||
[Unreleased]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.42-prealpha...HEAD
|
||||
[0.0.42-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.41-prealpha...v0.0.42-prealpha
|
||||
[0.0.41-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.40-prealpha...v0.0.41-prealpha
|
||||
[0.0.40-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.39-prealpha...v0.0.40-prealpha
|
||||
[0.0.39-prealpha]: https://github.com/jasongdove/ErsatzTV/compare/v0.0.38-prealpha...v0.0.39-prealpha
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,5 +6,6 @@ namespace ErsatzTV.Application.ProgramSchedules
|
||||
int Id,
|
||||
string Name,
|
||||
PlaybackOrder MediaCollectionPlaybackOrder,
|
||||
bool KeepMultiPartEpisodesTogether);
|
||||
bool KeepMultiPartEpisodesTogether,
|
||||
bool TreatCollectionsAsShows);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(1000)]
|
||||
public void State_Should_Reset_When_Invalid()
|
||||
{
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 10 };
|
||||
|
||||
var chronologicalContent = new ChronologicalMediaCollectionEnumerator(contents, state);
|
||||
|
||||
chronologicalContent.State.Index.Should().Be(0);
|
||||
chronologicalContent.State.Seed.Should().Be(0);
|
||||
}
|
||||
|
||||
private static List<MediaItem> Episodes(int count) =>
|
||||
Range(1, count).Map(
|
||||
i => (MediaItem) new Episode
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
|
||||
[Test]
|
||||
[Timeout(2000)]
|
||||
public async Task ZeroDurationItem_Should_Abort()
|
||||
public async Task OnlyZeroDurationItem_Should_Abort()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
@@ -55,6 +55,27 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
result.Items.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ZeroDurationItem_Should_BeSkipped()
|
||||
{
|
||||
var mediaItems = new List<MediaItem>
|
||||
{
|
||||
TestMovie(1, TimeSpan.Zero, DateTime.Today),
|
||||
TestMovie(2, TimeSpan.FromHours(6), DateTime.Today)
|
||||
};
|
||||
|
||||
(PlayoutBuilder builder, Playout playout) = TestDataFloodForItems(mediaItems, PlaybackOrder.Random);
|
||||
DateTimeOffset start = HoursAfterMidnight(0);
|
||||
DateTimeOffset finish = start + TimeSpan.FromHours(6);
|
||||
|
||||
Playout result = await builder.BuildPlayoutItems(playout, start, finish);
|
||||
|
||||
result.Items.Count.Should().Be(1);
|
||||
result.Items.Head().MediaItemId.Should().Be(2);
|
||||
result.Items.Head().StartOffset.TimeOfDay.Should().Be(TimeSpan.Zero);
|
||||
result.Items.Head().FinishOffset.TimeOfDay.Should().Be(TimeSpan.FromHours(6));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task InitialFlood_Should_StartAtMidnight()
|
||||
{
|
||||
|
||||
@@ -78,6 +78,16 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(1000)]
|
||||
public void State_Index_Should_Continue_Past_End_Of_Items()
|
||||
{
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 10, Seed = KnownSeed };
|
||||
|
||||
var _ = new RandomizedMediaCollectionEnumerator(contents, state);
|
||||
}
|
||||
|
||||
private static List<MediaItem> Episodes(int count) =>
|
||||
Range(1, count).Map(
|
||||
i => (MediaItem) new Episode
|
||||
|
||||
@@ -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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
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++)
|
||||
{
|
||||
@@ -116,6 +116,19 @@ namespace ErsatzTV.Core.Tests.Scheduling
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(1000)]
|
||||
public void State_Should_Reset_When_Invalid()
|
||||
{
|
||||
List<MediaItem> contents = Episodes(10);
|
||||
var state = new CollectionEnumeratorState { Index = 10, Seed = MagicSeed };
|
||||
|
||||
var shuffledContent = new ShuffledMediaCollectionEnumerator(contents, state, false, false);
|
||||
|
||||
shuffledContent.State.Index.Should().Be(0);
|
||||
shuffledContent.State.Seed.Should().NotBe(MagicSeed);
|
||||
}
|
||||
|
||||
private static List<MediaItem> Episodes(int count) =>
|
||||
Range(1, count).Map(
|
||||
i => (MediaItem) new Episode
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -18,6 +18,13 @@ namespace ErsatzTV.Core.Scheduling
|
||||
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalMediaComparer()).ToList();
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
|
||||
if (state.Index >= _sortedMediaItems.Count)
|
||||
{
|
||||
state.Index = 0;
|
||||
state.Seed = 0;
|
||||
}
|
||||
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext();
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,17 +80,52 @@ namespace ErsatzTV.Core.Scheduling
|
||||
playout.Channel.Number,
|
||||
playout.Channel.Name);
|
||||
|
||||
foreach ((CollectionKey _, List<MediaItem> items) in collectionMediaItems)
|
||||
{
|
||||
var zeroItems = new List<MediaItem>();
|
||||
|
||||
foreach (MediaItem item in items)
|
||||
{
|
||||
bool isZero = item switch
|
||||
{
|
||||
Movie m => await m.MediaVersions.Map(v => v.Duration).HeadOrNone().IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
Episode e => await e.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
MusicVideo mv => await mv.MediaVersions.Map(v => v.Duration).HeadOrNone()
|
||||
.IfNoneAsync(TimeSpan.Zero) ==
|
||||
TimeSpan.Zero,
|
||||
_ => true
|
||||
};
|
||||
|
||||
if (isZero)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping media item with zero duration {MediaItem} - {MediaItemTitle}",
|
||||
item.Id,
|
||||
DisplayTitle(item));
|
||||
|
||||
zeroItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
items.RemoveAll(i => zeroItems.Contains(i));
|
||||
}
|
||||
|
||||
// this guard needs to be below the place where we modify the collections (by removing zero-duration items)
|
||||
Option<CollectionKey> emptyCollection =
|
||||
collectionMediaItems.Find(c => !c.Value.Any()).Map(c => c.Key);
|
||||
if (emptyCollection.IsSome)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; collection {@CollectionKey} has no items!",
|
||||
"Unable to rebuild playout; collection {@CollectionKey} has no valid items!",
|
||||
emptyCollection.ValueUnsafe());
|
||||
|
||||
return playout;
|
||||
}
|
||||
|
||||
// leaving this guard in for a while to ensure the zero item removal is working properly
|
||||
Option<CollectionKey> zeroDurationCollection = collectionMediaItems.Find(
|
||||
c => c.Value.Any(
|
||||
mi => mi switch
|
||||
@@ -106,7 +141,7 @@ namespace ErsatzTV.Core.Scheduling
|
||||
if (zeroDurationCollection.IsSome)
|
||||
{
|
||||
_logger.LogError(
|
||||
"Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
|
||||
"BUG: Unable to rebuild playout; collection {@CollectionKey} contains items with zero duration!",
|
||||
zeroDurationCollection.ValueUnsafe());
|
||||
|
||||
return playout;
|
||||
@@ -471,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);
|
||||
|
||||
@@ -18,14 +18,21 @@ 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)
|
||||
{
|
||||
state.Index = 0;
|
||||
state.Seed = new Random(state.Seed).Next();
|
||||
}
|
||||
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Shuffle(_mediaItems, _random);
|
||||
|
||||
|
||||
+2889
File diff suppressed because it is too large
Load Diff
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 @@
|
||||
_model.Name = viewModel.Name;
|
||||
_model.MediaCollectionPlaybackOrder = viewModel.MediaCollectionPlaybackOrder;
|
||||
_model.KeepMultiPartEpisodesTogether = viewModel.KeepMultiPartEpisodesTogether;
|
||||
_model.TreatCollectionsAsShows = viewModel.TreatCollectionsAsShows;
|
||||
},
|
||||
() => _navigationManager.NavigateTo("404"));
|
||||
}
|
||||
|
||||
@@ -76,8 +76,10 @@ namespace ErsatzTV.Services
|
||||
using IServiceScope scope = _serviceScopeFactory.CreateScope();
|
||||
TvContext dbContext = scope.ServiceProvider.GetRequiredService<TvContext>();
|
||||
|
||||
List<int> playoutIds = await dbContext.Playouts.Map(p => p.Id).ToListAsync(cancellationToken);
|
||||
foreach (int playoutId in playoutIds)
|
||||
List<Playout> playouts = await dbContext.Playouts
|
||||
.Include(p => p.Channel)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (int playoutId in playouts.OrderBy(p => decimal.Parse(p.Channel.Number)).Map(p => p.Id))
|
||||
{
|
||||
await _workerChannel.WriteAsync(new BuildPlayout(playoutId), cancellationToken);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user