add season, episode playback order for shows (#1243)
This commit is contained in:
@@ -4,6 +4,11 @@ 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
|
||||||
|
- Add `Season, Episode` playback order
|
||||||
|
- This is currently *only* available when a show is added directly to a schedule
|
||||||
|
- This will ignore release date and sort exclusively by season number and then by episode number
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Limit `HLS Direct` streams to realtime speed
|
- Limit `HLS Direct` streams to realtime speed
|
||||||
- Fix `Reset Playout` button to use worker thread instead of UI thread
|
- Fix `Reset Playout` button to use worker thread instead of UI thread
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ public abstract class ProgramScheduleItemCommandBase
|
|||||||
case PlaybackOrder.Chronological:
|
case PlaybackOrder.Chronological:
|
||||||
case PlaybackOrder.Random:
|
case PlaybackOrder.Random:
|
||||||
case PlaybackOrder.MultiEpisodeShuffle:
|
case PlaybackOrder.MultiEpisodeShuffle:
|
||||||
|
case PlaybackOrder.SeasonEpisode:
|
||||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||||
case PlaybackOrder.Shuffle:
|
case PlaybackOrder.Shuffle:
|
||||||
case PlaybackOrder.ShuffleInOrder:
|
case PlaybackOrder.ShuffleInOrder:
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ public class ChronologicalContentTests
|
|||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
ReleaseDate = new DateTime(2020, 1, i)
|
ReleaseDate = new DateTime(2020, 1, i),
|
||||||
|
EpisodeNumber = 20 - i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Scheduling;
|
||||||
|
using FluentAssertions;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class SeasonEpisodeContentTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Episodes_Should_Sort_By_EpisodeNumber()
|
||||||
|
{
|
||||||
|
List<MediaItem> contents = Episodes(10);
|
||||||
|
var state = new CollectionEnumeratorState();
|
||||||
|
|
||||||
|
var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state);
|
||||||
|
|
||||||
|
for (var i = 1; i <= 10; i++)
|
||||||
|
{
|
||||||
|
chronologicalContent.Current.IsSome.Should().BeTrue();
|
||||||
|
chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||||
|
chronologicalContent.MoveNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void State_Index_Should_Increment()
|
||||||
|
{
|
||||||
|
List<MediaItem> contents = Episodes(10);
|
||||||
|
var state = new CollectionEnumeratorState();
|
||||||
|
|
||||||
|
var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state);
|
||||||
|
|
||||||
|
for (var i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
chronologicalContent.State.Index.Should().Be(i % 10);
|
||||||
|
chronologicalContent.MoveNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void State_Should_Impact_Iterator_Start()
|
||||||
|
{
|
||||||
|
List<MediaItem> contents = Episodes(10);
|
||||||
|
var state = new CollectionEnumeratorState { Index = 5 };
|
||||||
|
|
||||||
|
var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(contents, state);
|
||||||
|
|
||||||
|
for (var i = 6; i <= 10; i++)
|
||||||
|
{
|
||||||
|
chronologicalContent.Current.IsSome.Should().BeTrue();
|
||||||
|
chronologicalContent.Current.Map(x => x.Id).IfNone(-1).Should().Be(i);
|
||||||
|
chronologicalContent.State.Index.Should().Be(i - 1);
|
||||||
|
chronologicalContent.MoveNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Timeout(1000)]
|
||||||
|
public void State_Should_Reset_When_Invalid()
|
||||||
|
{
|
||||||
|
List<MediaItem> contents = Episodes(10);
|
||||||
|
var state = new CollectionEnumeratorState { Index = 10 };
|
||||||
|
|
||||||
|
var chronologicalContent = new SeasonEpisodeMediaCollectionEnumerator(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
|
||||||
|
{
|
||||||
|
Id = i,
|
||||||
|
EpisodeMetadata = new List<EpisodeMetadata>
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
ReleaseDate = new DateTime(2020, 1, 20 - i),
|
||||||
|
EpisodeNumber = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.Reverse()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
@@ -6,5 +6,6 @@ public enum PlaybackOrder
|
|||||||
Random = 2,
|
Random = 2,
|
||||||
Shuffle = 3,
|
Shuffle = 3,
|
||||||
ShuffleInOrder = 4,
|
ShuffleInOrder = 4,
|
||||||
MultiEpisodeShuffle = 5
|
MultiEpisodeShuffle = 5,
|
||||||
|
SeasonEpisode = 6
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,20 @@ public class MultiCollectionGroup : GroupedMediaItem
|
|||||||
switch (collectionWithItems.PlaybackOrder)
|
switch (collectionWithItems.PlaybackOrder)
|
||||||
{
|
{
|
||||||
case PlaybackOrder.Chronological:
|
case PlaybackOrder.Chronological:
|
||||||
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer())
|
{
|
||||||
.ToList();
|
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new ChronologicalMediaComparer())
|
||||||
First = sortedItems.Head();
|
.ToList();
|
||||||
Additional = sortedItems.Tail().ToList();
|
First = sortedItems.Head();
|
||||||
|
Additional = sortedItems.Tail().ToList();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PlaybackOrder.SeasonEpisode:
|
||||||
|
{
|
||||||
|
var sortedItems = collectionWithItems.MediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer())
|
||||||
|
.ToList();
|
||||||
|
First = sortedItems.Head();
|
||||||
|
Additional = sortedItems.Tail().ToList();
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new NotSupportedException(
|
throw new NotSupportedException(
|
||||||
|
|||||||
@@ -772,6 +772,17 @@ public class PlayoutBuilder : IPlayoutBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
|
return new ChronologicalMediaCollectionEnumerator(mediaItems, state);
|
||||||
|
case PlaybackOrder.SeasonEpisode:
|
||||||
|
if (randomStartPoint)
|
||||||
|
{
|
||||||
|
state = new CollectionEnumeratorState
|
||||||
|
{
|
||||||
|
Seed = state.Seed,
|
||||||
|
Index = Random.Next(0, mediaItems.Count - 1)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SeasonEpisodeMediaCollectionEnumerator(mediaItems, state);
|
||||||
case PlaybackOrder.Random:
|
case PlaybackOrder.Random:
|
||||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||||
case PlaybackOrder.ShuffleInOrder:
|
case PlaybackOrder.ShuffleInOrder:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling;
|
||||||
|
|
||||||
|
public sealed class SeasonEpisodeMediaCollectionEnumerator : IMediaCollectionEnumerator
|
||||||
|
{
|
||||||
|
private readonly IList<MediaItem> _sortedMediaItems;
|
||||||
|
|
||||||
|
public SeasonEpisodeMediaCollectionEnumerator(
|
||||||
|
IEnumerable<MediaItem> mediaItems,
|
||||||
|
CollectionEnumeratorState state)
|
||||||
|
{
|
||||||
|
_sortedMediaItems = mediaItems.OrderBy(identity, new SeasonEpisodeMediaComparer()).ToList();
|
||||||
|
|
||||||
|
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||||
|
|
||||||
|
if (state.Index >= _sortedMediaItems.Count)
|
||||||
|
{
|
||||||
|
state.Index = 0;
|
||||||
|
state.Seed = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (State.Index < state.Index)
|
||||||
|
{
|
||||||
|
MoveNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public CollectionEnumeratorState State { get; }
|
||||||
|
|
||||||
|
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
|
||||||
|
|
||||||
|
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
|
||||||
|
|
||||||
|
public Option<MediaItem> Peek(int offset) =>
|
||||||
|
_sortedMediaItems.Any() ? _sortedMediaItems[(State.Index + offset) % _sortedMediaItems.Count] : None;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using ErsatzTV.Core.Domain;
|
||||||
|
|
||||||
|
namespace ErsatzTV.Core.Scheduling;
|
||||||
|
|
||||||
|
internal class SeasonEpisodeMediaComparer : IComparer<MediaItem>
|
||||||
|
{
|
||||||
|
public int Compare(MediaItem x, MediaItem y)
|
||||||
|
{
|
||||||
|
if (x == null || y == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int season1 = x switch
|
||||||
|
{
|
||||||
|
Episode e => e.Season?.SeasonNumber ?? int.MaxValue,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
int season2 = y switch
|
||||||
|
{
|
||||||
|
Episode e => e.Season?.SeasonNumber ?? int.MaxValue,
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (season1 != season2)
|
||||||
|
{
|
||||||
|
return season1.CompareTo(season2);
|
||||||
|
}
|
||||||
|
|
||||||
|
int episode1 = x switch
|
||||||
|
{
|
||||||
|
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||||
|
em => em.EpisodeNumber,
|
||||||
|
() => int.MaxValue),
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
int episode2 = y switch
|
||||||
|
{
|
||||||
|
Episode e => e.EpisodeMetadata.HeadOrNone().Match(
|
||||||
|
em => em.EpisodeNumber,
|
||||||
|
() => int.MaxValue),
|
||||||
|
_ => int.MaxValue
|
||||||
|
};
|
||||||
|
|
||||||
|
if (episode1 != episode2)
|
||||||
|
{
|
||||||
|
return episode1.CompareTo(episode2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return x.Id.CompareTo(y.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -200,6 +200,7 @@
|
|||||||
break;
|
break;
|
||||||
case ProgramScheduleItemCollectionType.TelevisionShow:
|
case ProgramScheduleItemCollectionType.TelevisionShow:
|
||||||
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
|
||||||
|
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
|
||||||
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
|
||||||
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
|
||||||
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
|
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
|
||||||
|
|||||||
Reference in New Issue
Block a user