add playlist item count and shuffle playlist items (#2407)

* marathon cleanup

* add playlist item count, and shuffle playlist items
This commit is contained in:
Jason Dove
2025-09-12 09:19:05 -05:00
committed by GitHub
parent 4e065fe922
commit 17c7774603
19 changed files with 12971 additions and 24 deletions

View File

@@ -22,6 +22,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Empty or zero batch size means play all items from each group before advancing
- Any other value means play the specified number of items before advancing to the next group
- Log API requests when `Request Logging Minimum Log Level` is set to `Debug`
- Add `Count` setting to each playlist item
- Previously, when `Play All` was unchecked, this was implicitly 1
- Now, the playlist can play a specific number of items from the collection before moving to the next playlist item
- Classic schedules: add `Shuffle Playlist Items` setting to shuffle the order of playlist items
- Shuffling happens initially (on playout reset), and after all items from the *entire playlist* have been played
### Fixed
- Fix transcoding content with bt709/pc color metadata

View File

@@ -10,5 +10,6 @@ public record ReplacePlaylistItem(
int? SmartCollectionId,
int? MediaItemId,
PlaybackOrder PlaybackOrder,
int? Count,
bool PlayAll,
bool IncludeInProgramGuide);

View File

@@ -46,6 +46,7 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
SmartCollectionId = item.SmartCollectionId,
MediaItemId = item.MediaItemId,
PlaybackOrder = item.PlaybackOrder,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};

View File

@@ -89,6 +89,7 @@ internal static class Mapper
_ => null
},
playlistItem.PlaybackOrder,
playlistItem.Count,
playlistItem.PlayAll,
playlistItem.IncludeInProgramGuide);
}

View File

@@ -12,5 +12,6 @@ public record PlaylistItemViewModel(
SmartCollectionViewModel SmartCollection,
NamedMediaItemViewModel MediaItem,
PlaybackOrder PlaybackOrder,
int? Count,
bool PlayAll,
bool IncludeInProgramGuide);

View File

@@ -146,6 +146,145 @@ public class PlaylistEnumeratorTests
items.ShouldBe([10, 20, 30, 31, 10, 21, 30, 31]);
}
[Test]
public async Task Shuffled_Playlist_Should_Honor_PlayAll()
{
// this isn't needed for chronological, so no need to implement anything
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10)]
},
{
new PlaylistItem
{
Id = 2,
Index = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = true,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20), FakeMovie(21)]
},
{
new PlaylistItem
{
Id = 3,
Index = 2,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 3
},
[FakeMovie(30)]
}
};
var state = new CollectionEnumeratorState { Seed = 1 };
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
state,
shufflePlaylistItems: true,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
}
// with seed 1, shuffle order of (1,2,3) is (2,3,1)
// correct playout should be item 2 (all), item 3 (1), item 1 (1)
// which is media items (20, 21), (30), (10)
items.ShouldBe([20, 21, 30, 10]);
}
[Test]
public async Task Shuffled_Playlist_Should_Honor_Custom_Count()
{
// this isn't needed for chronological, so no need to implement anything
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
{
{
new PlaylistItem
{
Id = 1,
Index = 0,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
Count = 2,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 1
},
[FakeMovie(10), FakeMovie(11), FakeMovie(12)]
},
{
new PlaylistItem
{
Id = 2,
Index = 1,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 2
},
[FakeMovie(20)]
},
{
new PlaylistItem
{
Id = 3,
Index = 2,
PlaybackOrder = PlaybackOrder.Chronological,
PlayAll = false,
CollectionType = ProgramScheduleItemCollectionType.Collection,
CollectionId = 3
},
[FakeMovie(30)]
}
};
var state = new CollectionEnumeratorState { Seed = 1 };
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
repo,
playlistItemMap,
state,
shufflePlaylistItems: true,
batchSize: Option<int>.None,
CancellationToken.None);
var items = new List<int>();
for (var i = 0; i < 4; i++)
{
items.AddRange(enumerator.Current.Map(mi => mi.Id));
enumerator.MoveNext();
}
// with seed 1, shuffle order of (1,2,3) is (2,3,1)
// correct playout should be item 2 (1), item 3 (1), item 1 (2)
// which is media items (20), (30), (10, 11)
items.ShouldBe([20, 30, 10, 11]);
}
private static Movie FakeMovie(int id) => new()
{
Id = id,

View File

@@ -16,6 +16,7 @@ public class PlaylistItem
public int? SmartCollectionId { get; set; }
public SmartCollection SmartCollection { get; set; }
public PlaybackOrder PlaybackOrder { get; set; }
public int? Count { get; set; }
public bool PlayAll { get; set; }
public bool IncludeInProgramGuide { get; set; }
}

View File

@@ -11,10 +11,9 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
private readonly System.Collections.Generic.HashSet<int> _remainingMediaItemIds = [];
private System.Collections.Generic.HashSet<int> _allMediaItemIds;
private System.Collections.Generic.HashSet<int> _idsToIncludeInEPG;
private IList<bool> _playAll;
private CloneableRandom _random;
private bool _shufflePlaylistItems;
private List<IMediaCollectionEnumerator> _sortedEnumerators;
private List<EnumeratorPlayAllCount> _sortedEnumerators;
private int _itemsTakenFromCurrent;
private Option<int> _batchSize = Option<int>.None;
@@ -24,11 +23,11 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public int CountForRandom => _allMediaItemIds.Count;
public int CountForFiller => _sortedEnumerators.Select((t, i) => _playAll[i] ? t.Count : 1).Sum();
public int CountForFiller => _sortedEnumerators.Select(t => t.PlayAll ? t.Enumerator.Count : 1).Sum();
public ImmutableList<PlaylistEnumeratorCollectionKey> ChildEnumerators { get; private set; }
public bool CurrentEnumeratorPlayAll => _playAll[EnumeratorIndex];
public bool CurrentEnumeratorPlayAll => _sortedEnumerators[EnumeratorIndex].PlayAll;
public int EnumeratorIndex { get; private set; }
@@ -39,7 +38,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public CollectionEnumeratorState State { get; private set; }
public Option<MediaItem> Current => _sortedEnumerators.Count > 0
? _sortedEnumerators[EnumeratorIndex].Current
? _sortedEnumerators[EnumeratorIndex].Enumerator.Current
: Option<MediaItem>.None;
public Option<bool> CurrentIncludeInProgramGuide
@@ -61,19 +60,34 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
public void MoveNext()
{
foreach (MediaItem maybeMediaItem in _sortedEnumerators[EnumeratorIndex].Current)
foreach (MediaItem maybeMediaItem in _sortedEnumerators[EnumeratorIndex].Enumerator.Current)
{
_remainingMediaItemIds.Remove(maybeMediaItem.Id);
}
_sortedEnumerators[EnumeratorIndex].MoveNext();
_sortedEnumerators[EnumeratorIndex].Enumerator.MoveNext();
_itemsTakenFromCurrent++;
bool shouldSwitchEnumerator = _batchSize.Match(
// move to the next enumerator if we've hit the batch size
batchSize => _itemsTakenFromCurrent >= batchSize,
// if we aren't playing all, or if we just finished playing all, move to the next enumerator
() => !_playAll[EnumeratorIndex] || _sortedEnumerators[EnumeratorIndex].State.Index == 0);
() =>
{
// if we just finished playing all, move to the next enumerator
if (_sortedEnumerators[EnumeratorIndex].PlayAll)
{
return _sortedEnumerators[EnumeratorIndex].Enumerator.State.Index == 0;
}
// if we have played the desired count, move to the next enumerator
if (_sortedEnumerators[EnumeratorIndex].Count is { } count)
{
return _itemsTakenFromCurrent >= count;
}
// otherwise, always move
return true;
});
if (shouldSwitchEnumerator)
{
@@ -82,7 +96,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
}
State.Index += 1;
if (_remainingMediaItemIds.Count == 0 && EnumeratorIndex == 0 && _sortedEnumerators[0].State.Index == 0)
if (_remainingMediaItemIds.Count == 0 && EnumeratorIndex == 0 &&
_sortedEnumerators[0].Enumerator.State.Index == 0)
{
State.Index = 0;
_remainingMediaItemIds.UnionWith(_allMediaItemIds);
@@ -109,7 +124,6 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
var result = new PlaylistEnumerator
{
_sortedEnumerators = [],
_playAll = [],
_idsToIncludeInEPG = [],
_shufflePlaylistItems = shufflePlaylistItems,
_batchSize = batchSize
@@ -135,8 +149,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
var collectionKey = CollectionKey.ForPlaylistItem(playlistItem);
if (enumeratorMap.TryGetValue(collectionKey, out IMediaCollectionEnumerator enumerator))
{
result._sortedEnumerators.Add(enumerator);
result._playAll.Add(playlistItem.PlayAll);
result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
continue;
}
@@ -193,8 +207,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
if (enumerator is not null)
{
enumeratorMap.Add(collectionKey, enumerator);
result._sortedEnumerators.Add(enumerator);
result._playAll.Add(playlistItem.PlayAll);
result._sortedEnumerators.Add(
new EnumeratorPlayAllCount(enumerator, playlistItem.PlayAll, playlistItem.Count));
}
}
@@ -234,7 +248,7 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
}
var childEnumerators = new List<PlaylistEnumeratorCollectionKey>();
foreach (IMediaCollectionEnumerator enumerator in result._sortedEnumerators)
foreach ((IMediaCollectionEnumerator enumerator, _, _) in result._sortedEnumerators)
{
foreach ((CollectionKey collectionKey, _) in enumeratorMap.Find(e => e.Value == enumerator))
{
@@ -247,15 +261,15 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
return result;
}
private List<IMediaCollectionEnumerator> ShufflePlaylistItems()
private List<EnumeratorPlayAllCount> ShufflePlaylistItems()
{
if (_sortedEnumerators.Count < 3)
{
return _sortedEnumerators;
}
IMediaCollectionEnumerator[] copy = _sortedEnumerators.ToArray();
IMediaCollectionEnumerator last = _sortedEnumerators.Last();
EnumeratorPlayAllCount[] copy = _sortedEnumerators.ToArray();
EnumeratorPlayAllCount last = _sortedEnumerators.Last();
do
{
@@ -270,4 +284,6 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
return copy.ToList();
}
private record EnumeratorPlayAllCount(IMediaCollectionEnumerator Enumerator, bool PlayAll, int? Count);
}

View File

@@ -1235,7 +1235,7 @@ public class PlayoutBuilder : IPlayoutBuilder
_mediaCollectionRepository,
playlistItemMap,
state,
shufflePlaylistItems: false,
marathonShuffleGroups,
batchSize: Option<int>.None,
cancellationToken);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.MySql.Migrations
{
/// <inheritdoc />
public partial class Add_PlaylistItemCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Count",
table: "PlaylistItem",
type: "int",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Count",
table: "PlaylistItem");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder);
@@ -1777,6 +1777,9 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
b.Property<int>("CollectionType")
.HasColumnType("int");
b.Property<int?>("Count")
.HasColumnType("int");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("tinyint(1)");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
{
/// <inheritdoc />
public partial class Add_PlaylistItemCount : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "Count",
table: "PlaylistItem",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Count",
table: "PlaylistItem");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("ErsatzTV.Core.Domain.Actor", b =>
{
@@ -1690,6 +1690,9 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
b.Property<int>("CollectionType")
.HasColumnType("INTEGER");
b.Property<int?>("Count")
.HasColumnType("INTEGER");
b.Property<bool>("IncludeInProgramGuide")
.HasColumnType("INTEGER");

View File

@@ -53,6 +53,7 @@
<col/>
<col/>
<col/>
<col/>
<col style="width: 240px;"/>
</MudHidden>
</ColGroup>
@@ -60,6 +61,7 @@
<MudTh>Item Type</MudTh>
<MudTh>Item</MudTh>
<MudTh>Playback Order</MudTh>
<MudTh>Count</MudTh>
<MudTh>Play All</MudTh>
<MudTh>Show In EPG</MudTh>
<MudTh/>
@@ -77,7 +79,12 @@
</MudTd>
<MudTd DataLabel="Playback Order">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.PlaybackOrder > 0 ? context.PlaybackOrder : "")
@(context.PlaybackOrder > 0 ? context.PlaybackOrder : string.Empty)
</MudText>
</MudTd>
<MudTd DataLabel="Count">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.Count is not null ? context.Count : (context.PlayAll ? string.Empty : "1"))
</MudText>
</MudTd>
<MudTd DataLabel="Play All">
@@ -298,6 +305,12 @@
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Count</MudText>
</div>
<MudTextField @bind-Value="@_selectedItem.Count" For="@(() => _selectedItem.Count)"/>
</MudStack>
}
else if (_previewItems is not null)
{
@@ -466,6 +479,7 @@ else if (_previewItems is not null)
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
PlaybackOrder = item.PlaybackOrder,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
@@ -494,6 +508,7 @@ else if (_previewItems is not null)
MultiCollection = item.MultiCollection,
SmartCollection = item.SmartCollection,
MediaItem = item.MediaItem,
Count = item.Count,
PlayAll = item.PlayAll,
IncludeInProgramGuide = item.IncludeInProgramGuide
};
@@ -552,6 +567,7 @@ else if (_previewItems is not null)
item.SmartCollection?.Id,
item.MediaItem?.MediaItemId,
item.PlaybackOrder,
item.Count,
item.PlayAll,
item.IncludeInProgramGuide)).ToList();

View File

@@ -365,6 +365,7 @@
<MudSelectItem Value="PlaybackOrder.MultiEpisodeShuffle">Multi-Episode Shuffle</MudSelectItem>
break;
case ProgramScheduleItemCollectionType.Playlist:
<MudSelectItem Value="PlaybackOrder.None">Playlist</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
@@ -412,6 +413,17 @@
HelperText="How many items to play from each group before advancing; empty or zero will play all items"/>
</MudStack>
}
else if (_selectedItem.CollectionType is ProgramScheduleItemCollectionType.Playlist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Shuffle Playlist Items</MudText>
</div>
<MudCheckBox @bind-Value="_selectedItem.MarathonShuffleGroups"
For="@(() => _selectedItem.MarathonShuffleGroups)"
Dense="true"/>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Playout Mode</MudText>

View File

@@ -9,6 +9,8 @@ namespace ErsatzTV.ViewModels;
public class PlaylistItemEditViewModel : INotifyPropertyChanged
{
private ProgramScheduleItemCollectionType _collectionType;
private int? _count;
private bool _playAll;
public int Id { get; set; }
public int Index { get; set; }
@@ -81,7 +83,51 @@ public class PlaylistItemEditViewModel : INotifyPropertyChanged
public PlaybackOrder PlaybackOrder { get; set; }
public bool PlayAll { get; set; }
public int? Count
{
get => _count;
set
{
if (value == _count)
{
return;
}
_count = value;
OnPropertyChanged();
if (_count is not null)
{
_playAll = false;
OnPropertyChanged(nameof(PlayAll));
}
}
}
public bool PlayAll
{
get => _playAll;
set
{
if (value == _playAll)
{
return;
}
_playAll = value;
OnPropertyChanged();
if (_playAll)
{
_count = null;
}
else
{
_count ??= 1;
}
OnPropertyChanged(nameof(Count));
}
}
public bool IncludeInProgramGuide { get; set; }

View File

@@ -69,11 +69,17 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
MultipleMode = MultipleMode.Count;
}
if (_collectionType == ProgramScheduleItemCollectionType.Playlist)
{
PlaybackOrder = PlaybackOrder.None;
}
OnPropertyChanged(nameof(Collection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(MediaItem));
OnPropertyChanged(nameof(SmartCollection));
OnPropertyChanged(nameof(MultiCollection));
OnPropertyChanged(nameof(PlaybackOrder));
}
if (_collectionType == ProgramScheduleItemCollectionType.MultiCollection)
@@ -129,6 +135,13 @@ public class ProgramScheduleItemEditViewModel : INotifyPropertyChanged
MultipleMode = MultipleMode.Count;
}
if (_playbackOrder is not PlaybackOrder.Marathon)
{
MarathonGroupBy = MarathonGroupBy.None;
MarathonShuffleItems = false;
MarathonBatchSize = null;
}
OnPropertyChanged();
OnPropertyChanged(nameof(CanFillWithGroups));
OnPropertyChanged(nameof(MultipleMode));