feat(70): weighted / fair-share content distribution (WeightedShuffle playback order) #402
@@ -197,13 +197,25 @@ public class CreateChannelFromLineupHandler(
|
||||
|
||||
bool multiItem = normalized.Count >= 2;
|
||||
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder (mirrors PlayoutModeMustBeValid).
|
||||
// MultiCollection entries only support Shuffle / ShuffleInOrder / WeightedShuffle
|
||||
// (mirrors PlayoutModeMustBeValid -- keep the two lists in step).
|
||||
if (normalized.Any(i => i.CollectionType is CollectionType.MultiCollection) &&
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder))
|
||||
playbackOrder is not (PlaybackOrder.Shuffle or PlaybackOrder.ShuffleInOrder
|
||||
or PlaybackOrder.WeightedShuffle))
|
||||
{
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{playbackOrder}'");
|
||||
}
|
||||
|
||||
// A lineup of 2+ entries is persisted as a Playlist, and PlaylistEnumerator has no default arm: an
|
||||
// order it doesn't know leaves the enumerator null and the items are dropped from the playlist with
|
||||
// nothing reported. This is the second (and less obvious) persisting writer of
|
||||
// PlaylistItem.PlaybackOrder, alongside ReplacePlaylistItems (#70; the silent fallbacks are #403).
|
||||
if (multiItem && playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for a multi-item lineup; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
if (multiItem)
|
||||
{
|
||||
// The generated playlist cannot express rerun collections or nested playlists
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
@@ -7,7 +7,8 @@ public record CreateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record CreateMultiCollection(string Name, List<CreateMultiCollectionItem> Items)
|
||||
: IRequest<Either<BaseError, MultiCollectionViewModel>>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -51,42 +51,56 @@ public class CreateMultiCollectionHandler :
|
||||
private static Task<Validation<BaseError, MultiCollection>> Validate(
|
||||
TvContext dbContext,
|
||||
CreateMultiCollection request) =>
|
||||
ValidateName(dbContext, request).MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.CollectionId.HasValue)
|
||||
ValidateName(dbContext, request)
|
||||
.BindT(name => ValidateWeights(request).Map(_ => name))
|
||||
.MapT(name => new MultiCollection
|
||||
{
|
||||
Name = name,
|
||||
MultiCollectionItems = request.Items.Bind(i =>
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
if (i.CollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = i.CollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionItem>.None;
|
||||
})
|
||||
.ToList(),
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
{
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
MultiCollectionSmartItems = request.Items.Bind(i =>
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
});
|
||||
}
|
||||
if (i.SmartCollectionId.HasValue)
|
||||
{
|
||||
return Some(
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
});
|
||||
}
|
||||
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
return Option<MultiCollectionSmartItem>.None;
|
||||
})
|
||||
.ToList()
|
||||
});
|
||||
});
|
||||
|
||||
// Bounds are shared with the update path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set") while an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so neither a 0 nor a huge value can reach the rotation; this gate refuses input that has no
|
||||
// meaning on a share-of-airtime scale, and keeps create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(CreateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
|
||||
private static async Task<Validation<BaseError, string>> ValidateName(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -73,7 +73,30 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
ReplacePlaylistItems request,
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist));
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => playlist);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. PlaylistEnumerator has no
|
||||
// default arm, so an order it doesn't know leaves the enumerator null and the item is dropped from the
|
||||
// playlist silently -- refuse it at the write path instead of scheduling nothing at build time.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for playlist items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Playlist>> PlaylistMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -7,7 +7,8 @@ public record UpdateMultiCollectionItem(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record UpdateMultiCollection(
|
||||
int MultiCollectionId,
|
||||
|
||||
@@ -76,7 +76,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
CollectionId = i.CollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
})
|
||||
.ToList();
|
||||
var toRemove = c.MultiCollectionItems
|
||||
@@ -94,6 +95,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +112,8 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
SmartCollectionId = i.SmartCollectionId.Value,
|
||||
MultiCollectionId = c.Id,
|
||||
ScheduleAsGroup = i.ScheduleAsGroup,
|
||||
PlaybackOrder = i.PlaybackOrder
|
||||
PlaybackOrder = i.PlaybackOrder,
|
||||
Weight = i.Weight
|
||||
})
|
||||
.ToList();
|
||||
var toRemoveSmart = c.MultiCollectionSmartItems
|
||||
@@ -128,6 +131,7 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
{
|
||||
item.ScheduleAsGroup = incoming.ScheduleAsGroup;
|
||||
item.PlaybackOrder = incoming.PlaybackOrder;
|
||||
item.Weight = incoming.Weight;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,8 +160,20 @@ public class UpdateMultiCollectionHandler : IRequestHandler<UpdateMultiCollectio
|
||||
TvContext dbContext,
|
||||
UpdateMultiCollection request,
|
||||
CancellationToken cancellationToken) =>
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request))
|
||||
.Apply((collectionToUpdate, _) => collectionToUpdate);
|
||||
(await MultiCollectionMustExist(dbContext, request, cancellationToken),
|
||||
await ValidateName(dbContext, request),
|
||||
ValidateWeights(request))
|
||||
.Apply((collectionToUpdate, _, _) => collectionToUpdate);
|
||||
|
||||
// Bounds are shared with the create path so the two cannot drift -- they silently disagreed before #402:
|
||||
// EF's HasDefaultValue substitutes 1 for a 0 on INSERT (0 reads as "not set"), but an UPDATE writes the 0
|
||||
// through, so the same input landed differently depending on the verb. The enumerator clamps out-of-range
|
||||
// weights, so a 0 no longer removes the source; this gate is about refusing input that has no meaning on a
|
||||
// share-of-airtime scale, and about keeping create and update honest with each other. See #70.
|
||||
private static Validation<BaseError, Unit> ValidateWeights(UpdateMultiCollection request) =>
|
||||
request.Items.All(i => MultiCollectionItemWeight.IsValid(i.Weight))
|
||||
? Unit.Default
|
||||
: BaseError.New(MultiCollectionItemWeight.ValidationMessage);
|
||||
|
||||
private static Task<Validation<BaseError, MultiCollection>> MultiCollectionMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Application.Tree;
|
||||
using ErsatzTV.Core.Api.SmartCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -70,7 +70,8 @@ internal static class Mapper
|
||||
multiCollectionItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionItem.Collection),
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder);
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Weight);
|
||||
|
||||
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
|
||||
MultiCollectionSmartItem multiCollectionSmartItem) =>
|
||||
@@ -78,7 +79,8 @@ internal static class Mapper
|
||||
multiCollectionSmartItem.MultiCollectionId,
|
||||
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder);
|
||||
multiCollectionSmartItem.PlaybackOrder,
|
||||
multiCollectionSmartItem.Weight);
|
||||
|
||||
internal static TreeViewModel ProjectToViewModel(List<PlaylistGroup> playlistGroups) =>
|
||||
new(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,4 +6,5 @@ public record MultiCollectionItemViewModel(
|
||||
int MultiCollectionId,
|
||||
MediaCollectionViewModel Collection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
@@ -6,4 +6,5 @@ public record MultiCollectionSmartItemViewModel(
|
||||
int MultiCollectionId,
|
||||
SmartCollectionViewModel SmartCollection,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Errors;
|
||||
@@ -63,6 +63,9 @@ public abstract class ProgramScheduleItemCommandBase
|
||||
return BaseError.New($"Invalid playback order for multi collection: '{item.PlaybackOrder}'");
|
||||
case PlaybackOrder.Shuffle:
|
||||
case PlaybackOrder.ShuffleInOrder:
|
||||
// WeightedShuffle (#70) distributes across a multi collection's sources, so this is its
|
||||
// intended home. Listed explicitly rather than falling through the switch by omission.
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,7 +112,30 @@ public class ReplaceBlockItemsHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
BlockMustExist(dbContext, request.BlockId, cancellationToken)
|
||||
.BindT(block => MinutesMustBeValid(request, block))
|
||||
.BindT(block => BlockNameMustBeValid(dbContext, block, request))
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block));
|
||||
.BindT(block => CollectionTypesMustBeValid(request, block))
|
||||
.BindT(block => PlaybackOrdersMustBeSupported(request, block));
|
||||
|
||||
private static Validation<BaseError, Block> PlaybackOrdersMustBeSupported(
|
||||
ReplaceBlockItems request,
|
||||
Block block) =>
|
||||
request.Items
|
||||
.Map(item => PlaybackOrderMustBeSupported(item.PlaybackOrder))
|
||||
.Sequence()
|
||||
.Map(_ => block);
|
||||
|
||||
private static Validation<BaseError, Unit> PlaybackOrderMustBeSupported(PlaybackOrder playbackOrder)
|
||||
{
|
||||
// WeightedShuffle (#70) is implemented for classic schedule items only. BlockPlayoutBuilder filters
|
||||
// block items against an allow-list of orders and silently `continue`s past anything else, so an
|
||||
// unsupported order here means the block item never airs and nothing reports why.
|
||||
if (playbackOrder is PlaybackOrder.WeightedShuffle)
|
||||
{
|
||||
return BaseError.New(
|
||||
$"Playback order '{playbackOrder}' is not supported for block items; it is available on classic schedule items");
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Task<Validation<BaseError, Block>> BlockMustExist(
|
||||
TvContext dbContext,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
000 | 2026-01-15 00:00:00 - 2026-01-15 00:30:00 | None | Heavy Movie 01
|
||||
001 | 2026-01-15 00:30:00 - 2026-01-15 01:00:00 | None | Heavy Movie 02
|
||||
002 | 2026-01-15 01:00:00 - 2026-01-15 01:30:00 | None | Light Movie 04
|
||||
003 | 2026-01-15 01:30:00 - 2026-01-15 02:00:00 | None | Heavy Movie 01
|
||||
004 | 2026-01-15 02:00:00 - 2026-01-15 02:30:00 | None | Heavy Movie 02
|
||||
005 | 2026-01-15 02:30:00 - 2026-01-15 03:00:00 | None | Heavy Movie 01
|
||||
006 | 2026-01-15 03:00:00 - 2026-01-15 03:30:00 | None | Light Movie 03
|
||||
007 | 2026-01-15 03:30:00 - 2026-01-15 04:00:00 | None | Heavy Movie 02
|
||||
008 | 2026-01-15 04:00:00 - 2026-01-15 04:30:00 | None | Heavy Movie 01
|
||||
009 | 2026-01-15 04:30:00 - 2026-01-15 05:00:00 | None | Heavy Movie 02
|
||||
010 | 2026-01-15 05:00:00 - 2026-01-15 05:30:00 | None | Light Movie 01
|
||||
011 | 2026-01-15 05:30:00 - 2026-01-15 06:00:00 | None | Heavy Movie 01
|
||||
012 | 2026-01-15 06:00:00 - 2026-01-15 06:30:00 | None | Heavy Movie 02
|
||||
013 | 2026-01-15 06:30:00 - 2026-01-15 07:00:00 | None | Heavy Movie 01
|
||||
014 | 2026-01-15 07:00:00 - 2026-01-15 07:30:00 | None | Light Movie 02
|
||||
015 | 2026-01-15 07:30:00 - 2026-01-15 08:00:00 | None | Heavy Movie 02
|
||||
016 | 2026-01-15 08:00:00 - 2026-01-15 08:30:00 | None | Heavy Movie 01
|
||||
017 | 2026-01-15 08:30:00 - 2026-01-15 09:00:00 | None | Heavy Movie 02
|
||||
018 | 2026-01-15 09:00:00 - 2026-01-15 09:30:00 | None | Light Movie 02
|
||||
019 | 2026-01-15 09:30:00 - 2026-01-15 10:00:00 | None | Heavy Movie 01
|
||||
020 | 2026-01-15 10:00:00 - 2026-01-15 10:30:00 | None | Heavy Movie 02
|
||||
021 | 2026-01-15 10:30:00 - 2026-01-15 11:00:00 | None | Heavy Movie 01
|
||||
022 | 2026-01-15 11:00:00 - 2026-01-15 11:30:00 | None | Light Movie 04
|
||||
023 | 2026-01-15 11:30:00 - 2026-01-15 12:00:00 | None | Heavy Movie 02
|
||||
024 | 2026-01-15 12:00:00 - 2026-01-15 12:30:00 | None | Heavy Movie 01
|
||||
025 | 2026-01-15 12:30:00 - 2026-01-15 13:00:00 | None | Heavy Movie 02
|
||||
026 | 2026-01-15 13:00:00 - 2026-01-15 13:30:00 | None | Light Movie 01
|
||||
027 | 2026-01-15 13:30:00 - 2026-01-15 14:00:00 | None | Heavy Movie 01
|
||||
028 | 2026-01-15 14:00:00 - 2026-01-15 14:30:00 | None | Heavy Movie 02
|
||||
029 | 2026-01-15 14:30:00 - 2026-01-15 15:00:00 | None | Heavy Movie 01
|
||||
030 | 2026-01-15 15:00:00 - 2026-01-15 15:30:00 | None | Light Movie 03
|
||||
031 | 2026-01-15 15:30:00 - 2026-01-15 16:00:00 | None | Heavy Movie 02
|
||||
032 | 2026-01-15 16:00:00 - 2026-01-15 16:30:00 | None | Heavy Movie 01
|
||||
033 | 2026-01-15 16:30:00 - 2026-01-15 17:00:00 | None | Heavy Movie 02
|
||||
034 | 2026-01-15 17:00:00 - 2026-01-15 17:30:00 | None | Light Movie 02
|
||||
035 | 2026-01-15 17:30:00 - 2026-01-15 18:00:00 | None | Heavy Movie 01
|
||||
036 | 2026-01-15 18:00:00 - 2026-01-15 18:30:00 | None | Heavy Movie 02
|
||||
037 | 2026-01-15 18:30:00 - 2026-01-15 19:00:00 | None | Heavy Movie 01
|
||||
038 | 2026-01-15 19:00:00 - 2026-01-15 19:30:00 | None | Light Movie 03
|
||||
039 | 2026-01-15 19:30:00 - 2026-01-15 20:00:00 | None | Heavy Movie 02
|
||||
040 | 2026-01-15 20:00:00 - 2026-01-15 20:30:00 | None | Heavy Movie 01
|
||||
041 | 2026-01-15 20:30:00 - 2026-01-15 21:00:00 | None | Heavy Movie 02
|
||||
042 | 2026-01-15 21:00:00 - 2026-01-15 21:30:00 | None | Light Movie 01
|
||||
043 | 2026-01-15 21:30:00 - 2026-01-15 22:00:00 | None | Heavy Movie 01
|
||||
044 | 2026-01-15 22:00:00 - 2026-01-15 22:30:00 | None | Heavy Movie 02
|
||||
045 | 2026-01-15 22:30:00 - 2026-01-15 23:00:00 | None | Heavy Movie 01
|
||||
046 | 2026-01-15 23:00:00 - 2026-01-15 23:30:00 | None | Light Movie 04
|
||||
047 | 2026-01-15 23:30:00 - 2026-01-16 00:00:00 | None | Heavy Movie 02
|
||||
048 | 2026-01-16 00:00:00 - 2026-01-16 00:30:00 | None | Heavy Movie 01
|
||||
049 | 2026-01-16 00:30:00 - 2026-01-16 01:00:00 | None | Heavy Movie 02
|
||||
050 | 2026-01-16 01:00:00 - 2026-01-16 01:30:00 | None | Light Movie 02
|
||||
051 | 2026-01-16 01:30:00 - 2026-01-16 02:00:00 | None | Heavy Movie 01
|
||||
052 | 2026-01-16 02:00:00 - 2026-01-16 02:30:00 | None | Heavy Movie 02
|
||||
053 | 2026-01-16 02:30:00 - 2026-01-16 03:00:00 | None | Heavy Movie 01
|
||||
054 | 2026-01-16 03:00:00 - 2026-01-16 03:30:00 | None | Light Movie 04
|
||||
055 | 2026-01-16 03:30:00 - 2026-01-16 04:00:00 | None | Heavy Movie 02
|
||||
056 | 2026-01-16 04:00:00 - 2026-01-16 04:30:00 | None | Heavy Movie 01
|
||||
057 | 2026-01-16 04:30:00 - 2026-01-16 05:00:00 | None | Heavy Movie 02
|
||||
058 | 2026-01-16 05:00:00 - 2026-01-16 05:30:00 | None | Light Movie 01
|
||||
059 | 2026-01-16 05:30:00 - 2026-01-16 06:00:00 | None | Heavy Movie 01
|
||||
060 | 2026-01-16 06:00:00 - 2026-01-16 06:30:00 | None | Heavy Movie 02
|
||||
061 | 2026-01-16 06:30:00 - 2026-01-16 07:00:00 | None | Heavy Movie 01
|
||||
062 | 2026-01-16 07:00:00 - 2026-01-16 07:30:00 | None | Light Movie 03
|
||||
063 | 2026-01-16 07:30:00 - 2026-01-16 08:00:00 | None | Heavy Movie 02
|
||||
064 | 2026-01-16 08:00:00 - 2026-01-16 08:30:00 | None | Heavy Movie 01
|
||||
065 | 2026-01-16 08:30:00 - 2026-01-16 09:00:00 | None | Heavy Movie 02
|
||||
066 | 2026-01-16 09:00:00 - 2026-01-16 09:30:00 | None | Light Movie 01
|
||||
067 | 2026-01-16 09:30:00 - 2026-01-16 10:00:00 | None | Heavy Movie 01
|
||||
068 | 2026-01-16 10:00:00 - 2026-01-16 10:30:00 | None | Heavy Movie 02
|
||||
069 | 2026-01-16 10:30:00 - 2026-01-16 11:00:00 | None | Heavy Movie 01
|
||||
070 | 2026-01-16 11:00:00 - 2026-01-16 11:30:00 | None | Light Movie 02
|
||||
071 | 2026-01-16 11:30:00 - 2026-01-16 12:00:00 | None | Heavy Movie 02
|
||||
072 | 2026-01-16 12:00:00 - 2026-01-16 12:30:00 | None | Heavy Movie 01
|
||||
073 | 2026-01-16 12:30:00 - 2026-01-16 13:00:00 | None | Heavy Movie 02
|
||||
074 | 2026-01-16 13:00:00 - 2026-01-16 13:30:00 | None | Light Movie 03
|
||||
075 | 2026-01-16 13:30:00 - 2026-01-16 14:00:00 | None | Heavy Movie 01
|
||||
076 | 2026-01-16 14:00:00 - 2026-01-16 14:30:00 | None | Heavy Movie 02
|
||||
077 | 2026-01-16 14:30:00 - 2026-01-16 15:00:00 | None | Heavy Movie 01
|
||||
078 | 2026-01-16 15:00:00 - 2026-01-16 15:30:00 | None | Light Movie 04
|
||||
079 | 2026-01-16 15:30:00 - 2026-01-16 16:00:00 | None | Heavy Movie 02
|
||||
080 | 2026-01-16 16:00:00 - 2026-01-16 16:30:00 | None | Heavy Movie 01
|
||||
081 | 2026-01-16 16:30:00 - 2026-01-16 17:00:00 | None | Heavy Movie 02
|
||||
082 | 2026-01-16 17:00:00 - 2026-01-16 17:30:00 | None | Light Movie 03
|
||||
083 | 2026-01-16 17:30:00 - 2026-01-16 18:00:00 | None | Heavy Movie 01
|
||||
084 | 2026-01-16 18:00:00 - 2026-01-16 18:30:00 | None | Heavy Movie 02
|
||||
085 | 2026-01-16 18:30:00 - 2026-01-16 19:00:00 | None | Heavy Movie 01
|
||||
086 | 2026-01-16 19:00:00 - 2026-01-16 19:30:00 | None | Light Movie 02
|
||||
087 | 2026-01-16 19:30:00 - 2026-01-16 20:00:00 | None | Heavy Movie 02
|
||||
088 | 2026-01-16 20:00:00 - 2026-01-16 20:30:00 | None | Heavy Movie 01
|
||||
089 | 2026-01-16 20:30:00 - 2026-01-16 21:00:00 | None | Heavy Movie 02
|
||||
090 | 2026-01-16 21:00:00 - 2026-01-16 21:30:00 | None | Light Movie 04
|
||||
091 | 2026-01-16 21:30:00 - 2026-01-16 22:00:00 | None | Heavy Movie 01
|
||||
092 | 2026-01-16 22:00:00 - 2026-01-16 22:30:00 | None | Heavy Movie 02
|
||||
093 | 2026-01-16 22:30:00 - 2026-01-16 23:00:00 | None | Heavy Movie 01
|
||||
094 | 2026-01-16 23:00:00 - 2026-01-16 23:30:00 | None | Light Movie 01
|
||||
095 | 2026-01-16 23:30:00 - 2026-01-17 00:00:00 | None | Heavy Movie 02
|
||||
096 | 2026-01-17 00:00:00 - 2026-01-17 00:30:00 | None | Heavy Movie 01
|
||||
097 | 2026-01-17 00:30:00 - 2026-01-17 01:00:00 | None | Heavy Movie 02
|
||||
098 | 2026-01-17 01:00:00 - 2026-01-17 01:30:00 | None | Light Movie 04
|
||||
099 | 2026-01-17 01:30:00 - 2026-01-17 02:00:00 | None | Heavy Movie 01
|
||||
100 | 2026-01-17 02:00:00 - 2026-01-17 02:30:00 | None | Heavy Movie 02
|
||||
101 | 2026-01-17 02:30:00 - 2026-01-17 03:00:00 | None | Heavy Movie 01
|
||||
102 | 2026-01-17 03:00:00 - 2026-01-17 03:30:00 | None | Light Movie 02
|
||||
103 | 2026-01-17 03:30:00 - 2026-01-17 04:00:00 | None | Heavy Movie 02
|
||||
104 | 2026-01-17 04:00:00 - 2026-01-17 04:30:00 | None | Heavy Movie 01
|
||||
105 | 2026-01-17 04:30:00 - 2026-01-17 05:00:00 | None | Heavy Movie 02
|
||||
106 | 2026-01-17 05:00:00 - 2026-01-17 05:30:00 | None | Light Movie 01
|
||||
107 | 2026-01-17 05:30:00 - 2026-01-17 06:00:00 | None | Heavy Movie 01
|
||||
@@ -124,6 +124,16 @@ public class PlayoutBuildGoldenTests
|
||||
[Test]
|
||||
public Task Classic_shuffle() => Verify("classic-shuffle.txt", BuildShufflePlayout);
|
||||
|
||||
// Classic + PlaybackOrder.WeightedShuffle (#70): the weight only means anything end-to-end if it survives
|
||||
// MultiCollection -> MediaCollectionRepository -> CollectionWithItems.Weight -> ShuffleSourceBuilder ->
|
||||
// WeightedShuffleCollectionEnumerator. The unit tests pin the enumerator's sequence in isolation; this pins
|
||||
// that the real builder actually distributes by weight. The fixture is deliberately lopsided -- the LIGHTER
|
||||
// source (weight 1) is the LARGER collection (4 items vs 2) -- so a golden that merely tracked collection
|
||||
// size, or that ignored weight, would look obviously different from one that honors a 3:1 ratio.
|
||||
// Same determinism contract as Classic_shuffle: pinned Seed + Continue (Reset would randomize the seed).
|
||||
[Test]
|
||||
public Task Classic_weighted() => Verify("classic-weighted.txt", BuildWeightedPlayout);
|
||||
|
||||
[Test]
|
||||
[Explicit("Regenerates all playout goldens from current output; review the diff before committing.")]
|
||||
public async Task Regenerate_goldens()
|
||||
@@ -376,6 +386,183 @@ public class PlayoutBuildGoldenTests
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(List<PlayoutItem> Items, Dictionary<int, string> Titles)> BuildWeightedPlayout()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var (playoutId, titles) = await SeedWeightedData(cancellationToken);
|
||||
|
||||
var builder = new PlayoutBuilder(
|
||||
new ConfigElementRepository(_dbContextFactory),
|
||||
new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _dbContextFactory),
|
||||
new TelevisionRepository(_dbContextFactory, NullLogger<TelevisionRepository>.Instance),
|
||||
new ArtistRepository(_dbContextFactory),
|
||||
Substitute.For<IMultiEpisodeShuffleCollectionEnumeratorFactory>(),
|
||||
new MockFileSystem(),
|
||||
Substitute.For<IRerunHelper>(),
|
||||
NullLogger<PlayoutBuilder>.Instance);
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
Playout playout = await context.Playouts
|
||||
.Include(p => p.ProgramScheduleAnchors)
|
||||
.ThenInclude(a => a.EnumeratorState)
|
||||
.Include(p => p.FillGroupIndices)
|
||||
.ThenInclude(fgi => fgi.EnumeratorState)
|
||||
.SingleAsync(p => p.Id == playoutId, cancellationToken);
|
||||
|
||||
PlayoutReferenceData referenceData = await GetReferenceData(context, playoutId);
|
||||
|
||||
Either<BaseError, PlayoutBuildResult> result = await builder.Build(
|
||||
playout,
|
||||
referenceData,
|
||||
PlayoutBuildResult.Empty,
|
||||
PlayoutBuildMode.Continue,
|
||||
Start,
|
||||
Start.AddDays(2),
|
||||
cancellationToken);
|
||||
|
||||
PlayoutBuildResult buildResult = result.Match(
|
||||
r => r,
|
||||
error => throw new AssertionException($"Build returned error: {error.Value}"));
|
||||
|
||||
return (buildResult.AddedItems, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedWeightedData(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
var path = new LibraryPath { Path = "Weighted LibraryPath" };
|
||||
var library = new LocalLibrary
|
||||
{
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
Paths = new List<LibraryPath> { path },
|
||||
MediaSource = new LocalMediaSource()
|
||||
};
|
||||
await context.Libraries.AddAsync(library, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Titles carry their source so the golden reads as a rotation rather than a list of ids.
|
||||
// "Heavy" is the SMALL collection with weight 3; "Light" is the LARGE one with weight 1.
|
||||
async Task<List<Movie>> SeedMovies(string prefix, int count)
|
||||
{
|
||||
var movies = new List<Movie>();
|
||||
for (var i = 1; i <= count; i++)
|
||||
{
|
||||
movies.Add(
|
||||
new Movie
|
||||
{
|
||||
MediaVersions = new List<MediaVersion> { new() { Duration = TimeSpan.FromMinutes(30) } },
|
||||
MovieMetadata = new List<MovieMetadata>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Title = $"{prefix} Movie {i:D2}",
|
||||
ReleaseDate = new DateTime(2010, 1, 1).AddDays(i)
|
||||
}
|
||||
},
|
||||
LibraryPath = path,
|
||||
LibraryPathId = path.Id
|
||||
});
|
||||
}
|
||||
|
||||
await context.Movies.AddRangeAsync(movies, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
return movies;
|
||||
}
|
||||
|
||||
List<Movie> heavy = await SeedMovies("Heavy", 2);
|
||||
List<Movie> light = await SeedMovies("Light", 4);
|
||||
|
||||
var titles = heavy.Concat(light).ToDictionary(m => m.Id, m => m.MovieMetadata[0].Title);
|
||||
|
||||
var heavyCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Heavy Collection",
|
||||
MediaItems = heavy.Cast<MediaItem>().ToList()
|
||||
};
|
||||
var lightCollection = new Collection
|
||||
{
|
||||
Name = "Weighted Light Collection",
|
||||
MediaItems = light.Cast<MediaItem>().ToList()
|
||||
};
|
||||
await context.Collections.AddRangeAsync([heavyCollection, lightCollection], cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var multiCollection = new MultiCollection
|
||||
{
|
||||
Name = "Weighted Multi Collection",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = heavyCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 3
|
||||
},
|
||||
new MultiCollectionItem
|
||||
{
|
||||
CollectionId = lightCollection.Id,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 1
|
||||
}
|
||||
]
|
||||
};
|
||||
await context.MultiCollections.AddAsync(multiCollection, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var scheduleItems = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemDuration
|
||||
{
|
||||
MultiCollection = multiCollection,
|
||||
MultiCollectionId = multiCollection.Id,
|
||||
CollectionType = CollectionType.MultiCollection,
|
||||
PlayoutDuration = TimeSpan.FromHours(1),
|
||||
TailMode = TailMode.None,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle
|
||||
}
|
||||
};
|
||||
|
||||
var ffmpegProfile = new FFmpegProfile { Name = "Weighted FFmpeg Profile" };
|
||||
await context.FFmpegProfiles.AddAsync(ffmpegProfile, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Number/GUID must be globally unique: every golden fixture shares one in-memory DB.
|
||||
var channel = new Channel(Guid.Parse("00000000-0000-0000-0000-000000000005"))
|
||||
{
|
||||
Name = "Weighted Test Channel",
|
||||
Number = "5",
|
||||
FFmpegProfile = ffmpegProfile,
|
||||
FFmpegProfileId = ffmpegProfile.Id
|
||||
};
|
||||
await context.Channels.AddAsync(channel, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var schedule = new ProgramSchedule { Name = "Weighted Test Schedule", Items = scheduleItems };
|
||||
await context.ProgramSchedules.AddAsync(schedule, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var playout = new Playout
|
||||
{
|
||||
Channel = channel,
|
||||
ChannelId = channel.Id,
|
||||
ProgramSchedule = schedule,
|
||||
ProgramScheduleId = schedule.Id,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic,
|
||||
// Pinned so the weighted rotation is deterministic; Continue preserves it (Reset would overwrite).
|
||||
Seed = 7654321
|
||||
};
|
||||
await context.Playouts.AddAsync(playout, cancellationToken);
|
||||
await context.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return (playout.Id, titles);
|
||||
}
|
||||
|
||||
private async Task<(int PlayoutId, Dictionary<int, string> Titles)> SeedData(CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the weighted / fair-share distribution contract (#70). The sequence semantics here are the
|
||||
/// product decision, so they are asserted exactly rather than statistically.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class WeightedShuffleCollectionEnumeratorTests
|
||||
{
|
||||
// ids are allocated per source so an emitted item's source is identifiable from its id
|
||||
private const int SourceAFirstId = 100;
|
||||
private const int SourceBFirstId = 200;
|
||||
private const int SourceCFirstId = 300;
|
||||
|
||||
private static CollectionWithItems Source(string key, int firstId, int itemCount, int weight) =>
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
key,
|
||||
Enumerable.Range(firstId, itemCount)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
false,
|
||||
weight);
|
||||
|
||||
private static string SourceOf(int id) => id switch
|
||||
{
|
||||
>= SourceCFirstId => "C",
|
||||
>= SourceBFirstId => "B",
|
||||
_ => "A"
|
||||
};
|
||||
|
||||
private static List<string> TakeSourceSequence(WeightedShuffleCollectionEnumerator enumerator, int count)
|
||||
{
|
||||
var result = new List<string>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
enumerator.Current.IsSome.ShouldBeTrue();
|
||||
result.Add(SourceOf(enumerator.Current.ValueUnsafe().Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Weights_Three_To_One_Emit_A_A_B_A()
|
||||
{
|
||||
// the canonical smooth-WRR contract: 3:1 spreads B through the rotation (A A B A),
|
||||
// rather than draining A first (A A A B) the way PlaylistItem.Count does
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 3, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "B", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Equal_Weights_Are_Fair_Share_Regardless_Of_Collection_Size()
|
||||
{
|
||||
// the heart of goal (2): a 20-item source must air as often as a 2-item source.
|
||||
// ShuffleInOrder cannot do this -- it plays every item once, so airtime tracks size.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 20, 1),
|
||||
Source("B", SourceBFirstId, 2, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 40);
|
||||
|
||||
// equal weights => strict alternation, so the small source loops rather than falling silent
|
||||
sequence.Count(s => s == "A").ShouldBe(20);
|
||||
sequence.Count(s => s == "B").ShouldBe(20);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ties_Break_To_The_Earliest_Source_In_List_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 4, 1),
|
||||
Source("B", SourceBFirstId, 4, 1),
|
||||
Source("C", SourceCFirstId, 4, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 99, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// all accumulators tie every round, so list order decides
|
||||
TakeSourceSequence(enumerator, 6).ShouldBe(["A", "B", "C", "A", "B", "C"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Unweighted_Collection_Is_Fair_Share()
|
||||
{
|
||||
// a source arriving without an explicit weight must rotate as fair-share, never fall out
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(0, 0, "A", [new Movie { Id = SourceAFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false),
|
||||
new(0, 0, "B", [new Movie { Id = SourceBFirstId, MovieMetadata = [] }], true, PlaybackOrder.WeightedShuffle, false)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 7, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(0)]
|
||||
[TestCase(-5)]
|
||||
public void A_Non_Positive_Weight_Does_Not_Delete_The_Source(int weight)
|
||||
{
|
||||
// the write path bounds weight, but a row can predate that gate. Treating a 0/negative weight as a
|
||||
// filter would remove the source from the channel silently -- the exact failure this order avoids
|
||||
// everywhere else. It is clamped to the floor instead.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
Source("B", SourceBFirstId, 2, weight)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 21, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
TakeSourceSequence(enumerator, 8).ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Enormous_Weight_Does_Not_Overflow_The_Rotation()
|
||||
{
|
||||
// summing unclamped weights is checked arithmetic, so an out-of-range row would throw from inside a
|
||||
// playout build rather than merely schedule oddly
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, int.MaxValue),
|
||||
Source("B", SourceBFirstId, 2, int.MaxValue)
|
||||
};
|
||||
|
||||
WeightedShuffleCollectionEnumerator enumerator = null;
|
||||
Should.NotThrow(() => enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 5, Index = 0 },
|
||||
CancellationToken.None));
|
||||
|
||||
// both clamp to the ceiling, so they tie and alternate by list order
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "B", "A", "B"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[TestCase(12)]
|
||||
[TestCase(13)]
|
||||
[TestCase(20)]
|
||||
[TestCase(37)]
|
||||
public void Restoring_Past_A_Rotation_Wrap_Equals_Advancing_To_It(int target)
|
||||
{
|
||||
// the stateless claim has to hold ACROSS a wrap, not just within the first rotation: each wrap
|
||||
// re-derives the rotation from the new seed alone, so (Seed, Index) still determines position.
|
||||
// Cycle length here is 12, so every case but the first crosses at least one wrap.
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 909;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = advanced.State.Seed, Index = advanced.State.Index },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Restoring_At_An_Index_Equals_Advancing_To_It()
|
||||
{
|
||||
// the stateless contract: (Seed, Index) fully determines position, which is what lets the
|
||||
// existing CollectionEnumeratorState persistence carry this order with no per-source counters
|
||||
List<CollectionWithItems> Collections() =>
|
||||
[
|
||||
Source("A", SourceAFirstId, 5, 3),
|
||||
Source("B", SourceBFirstId, 3, 1)
|
||||
];
|
||||
|
||||
const int Seed = 4242;
|
||||
const int Target = 7;
|
||||
|
||||
var advanced = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = 0 },
|
||||
CancellationToken.None);
|
||||
for (var i = 0; i < Target; i++)
|
||||
{
|
||||
advanced.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
var restored = new WeightedShuffleCollectionEnumerator(
|
||||
Collections(),
|
||||
new CollectionEnumeratorState { Seed = Seed, Index = Target },
|
||||
CancellationToken.None);
|
||||
|
||||
restored.State.Index.ShouldBe(advanced.State.Index);
|
||||
restored.State.Seed.ShouldBe(advanced.State.Seed);
|
||||
restored.Current.ValueUnsafe().Id.ShouldBe(advanced.Current.ValueUnsafe().Id);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Single_Source_Emits_All_Of_Its_Items()
|
||||
{
|
||||
var collections = new List<CollectionWithItems> { Source("A", SourceAFirstId, 5, 3) };
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 11, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var seen = new System.Collections.Generic.HashSet<int>();
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
seen.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
seen.Count.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void An_Empty_Source_Is_Ignored_Rather_Than_Emitting_Nothing()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 2, 1),
|
||||
new(0, 0, "empty", [], true, PlaybackOrder.WeightedShuffle, false, 5)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 3, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// a heavily-weighted empty source must not starve the rotation or emit None
|
||||
TakeSourceSequence(enumerator, 4).ShouldBe(["A", "A", "A", "A"]);
|
||||
}
|
||||
|
||||
[Test]
|
||||
[Timeout(10_000)]
|
||||
public void Single_Item_Sources_Do_Not_Hang_On_Rotation_Wrap()
|
||||
{
|
||||
// regression: the wrap retries a rebuild to avoid an immediate repeat, but this order's lead item is
|
||||
// decided by weight, so the heaviest source always leads. With one item in it the lead is invariant and
|
||||
// an unbounded retry never terminates -- a hung playout build, not a wrong one. Found by the
|
||||
// non-vacuity control, which hung instead of failing.
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
Source("A", SourceAFirstId, 1, 3),
|
||||
Source("B", SourceBFirstId, 1, 1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 1234, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
// walk several full rotations so the wrap path is exercised repeatedly
|
||||
List<string> sequence = TakeSourceSequence(enumerator, 24);
|
||||
|
||||
sequence.ShouldContain("A");
|
||||
sequence.ShouldContain("B");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void No_Sources_Yields_No_Current()
|
||||
{
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
[],
|
||||
new CollectionEnumeratorState { Seed = 1, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
enumerator.Current.IsNone.ShouldBeTrue();
|
||||
enumerator.Count.ShouldBe(0);
|
||||
Should.NotThrow(() => enumerator.MoveNext(Option<DateTimeOffset>.None));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void A_Custom_Ordered_Source_Keeps_Its_Order()
|
||||
{
|
||||
var collections = new List<CollectionWithItems>
|
||||
{
|
||||
new(
|
||||
0,
|
||||
0,
|
||||
"A",
|
||||
Enumerable.Range(SourceAFirstId, 4)
|
||||
.Select(i => new Movie { Id = i, MovieMetadata = [] })
|
||||
.Cast<MediaItem>()
|
||||
.ToList(),
|
||||
true,
|
||||
PlaybackOrder.WeightedShuffle,
|
||||
true,
|
||||
1)
|
||||
};
|
||||
|
||||
var enumerator = new WeightedShuffleCollectionEnumerator(
|
||||
collections,
|
||||
new CollectionEnumeratorState { Seed = 555, Index = 0 },
|
||||
CancellationToken.None);
|
||||
|
||||
var ids = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
ids.Add(enumerator.Current.ValueUnsafe().Id);
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
ids.ShouldBe([SourceAFirstId, SourceAFirstId + 1, SourceAFirstId + 2, SourceAFirstId + 3]);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,8 @@ public record MultiCollectionItemResponseModel(
|
||||
int? SmartCollectionId,
|
||||
string Name,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record PagedMultiCollectionsResponseModel(
|
||||
int TotalCount,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionItem
|
||||
{
|
||||
@@ -8,4 +8,10 @@ public class MultiCollectionItem
|
||||
public Collection Collection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds for a multi-collection member's per-source <c>Weight</c> (#70).
|
||||
/// <para>
|
||||
/// The floor is <b>1, not 0</b>. A weight is a <i>share of airtime</i>, and 0 has no meaning on that scale —
|
||||
/// "don't play this source" is expressed by removing it from the multi collection, not by weighting it to
|
||||
/// nothing. Rejecting it here keeps that intent from being expressed in a way the rotation would have to
|
||||
/// interpret. <c>WeightedShuffleCollectionEnumerator</c> independently clamps to this range rather than
|
||||
/// trusting it, so a row that predates this gate rotates at the floor instead of vanishing; the two are
|
||||
/// belt-and-braces, not duplicates — <b>neither is redundant</b>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The ceiling is the same argument as the floor, not an arithmetic guard. A weight of a billion is not a
|
||||
/// share of airtime any more than 0 is, so it is refused at the boundary rather than silently reinterpreted.
|
||||
/// The rotation arithmetic is made safe by <c>EffectiveWeight</c>'s clamp (and by <c>CycleLength</c> summing
|
||||
/// to <c>long</c>) — <b>not</b> by this constant: with the clamp in place a sum cannot overflow whatever the
|
||||
/// stored value is, which is exactly why a row that predates this gate is still safe.
|
||||
/// <c>An_Enormous_Weight_Does_Not_Overflow_The_Rotation</c> pins that.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class MultiCollectionItemWeight
|
||||
{
|
||||
public const int Minimum = 1;
|
||||
public const int Maximum = 1000;
|
||||
public const int Default = 1;
|
||||
|
||||
public static bool IsValid(int weight) => weight is >= Minimum and <= Maximum;
|
||||
|
||||
public static string ValidationMessage =>
|
||||
$"Weight must be between {Minimum} and {Maximum}";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public class MultiCollectionSmartItem
|
||||
{
|
||||
@@ -8,4 +8,10 @@ public class MultiCollectionSmartItem
|
||||
public SmartCollection SmartCollection { get; set; }
|
||||
public bool ScheduleAsGroup { get; set; }
|
||||
public PlaybackOrder PlaybackOrder { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Relative share of airtime under <see cref="PlaybackOrder.WeightedShuffle" /> (#70). Defaults to 1,
|
||||
/// which is fair-share. Ignored by every other playback order.
|
||||
/// </summary>
|
||||
public int Weight { get; set; } = 1;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
namespace ErsatzTV.Core.Domain;
|
||||
|
||||
public enum PlaybackOrder
|
||||
{
|
||||
@@ -11,5 +11,9 @@ public enum PlaybackOrder
|
||||
MultiEpisodeShuffle = 5,
|
||||
SeasonEpisode = 6,
|
||||
RandomRotation = 7,
|
||||
Marathon = 8
|
||||
Marathon = 8,
|
||||
|
||||
// weighted / fair-share distribution (#70); Classic schedule items only -- write-path validation
|
||||
// rejects it elsewhere, because the other engines silently drop or substitute an unknown order
|
||||
WeightedShuffle = 9
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
@@ -9,4 +9,5 @@ public record CollectionWithItems(
|
||||
List<MediaItem> MediaItems,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder,
|
||||
bool UseCustomOrder);
|
||||
bool UseCustomOrder,
|
||||
int Weight = 1);
|
||||
|
||||
@@ -1217,7 +1217,8 @@ public class SchedulingEngine(
|
||||
return _state;
|
||||
}
|
||||
|
||||
private static Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
// instance rather than static so an unsupported order can be reported (#70)
|
||||
private Option<IMediaCollectionEnumerator> EnumeratorForContent(
|
||||
List<MediaItem> items,
|
||||
CollectionEnumeratorState state,
|
||||
PlaybackOrder playbackOrder,
|
||||
@@ -1235,6 +1236,12 @@ public class SchedulingEngine(
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// None means the caller's foreach never runs, so the content is simply absent from the playout with
|
||||
// nothing said. Report it instead of leaving a silently empty schedule (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by scripted scheduling; no content will be scheduled for this entry",
|
||||
playbackOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
@@ -1383,6 +1383,14 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
state,
|
||||
activeSchedule.RandomStartPoint,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.WeightedShuffle:
|
||||
return new WeightedShuffleCollectionEnumerator(
|
||||
await ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder(
|
||||
_mediaCollectionRepository,
|
||||
collectionKey,
|
||||
cancellationToken),
|
||||
state,
|
||||
cancellationToken);
|
||||
case PlaybackOrder.MultiEpisodeShuffle when
|
||||
collectionKey.CollectionType == CollectionType.TelevisionShow &&
|
||||
collectionKey.MediaItemId.HasValue:
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Weighted / fair-share distribution (issue #70). Picks a *source* by smooth weighted round-robin,
|
||||
/// then takes that source's next item. A source's <see cref="CollectionWithItems.Weight" /> is its
|
||||
/// relative share of airtime: weights 3:1 emit <c>A A B A</c> per rotation.
|
||||
/// <para>
|
||||
/// Fair-share is the equal-weights degenerate case (the default, since Weight defaults to 1):
|
||||
/// every source airs equally often regardless of library size, so a small show loops while a
|
||||
/// large one works through its items. This is what distinguishes it from
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose balanced shuffle plays every item
|
||||
/// exactly once per cycle and therefore leaves airtime proportional to collection size
|
||||
/// (it prevents clumping, not domination).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deterministic and stateless: the emitted sequence is a pure function of
|
||||
/// (<see cref="CollectionEnumeratorState.Seed" />, <see cref="CollectionEnumeratorState.Index" />),
|
||||
/// so it restores by replay exactly like its siblings and needs no per-source persisted counters.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class WeightedShuffleCollectionEnumerator : IMediaCollectionEnumerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Upper bound on the precomputed rotation, so a large collection paired with a low weight
|
||||
/// (picks needed grows as items * totalWeight / weight) can't allocate without limit. Hitting the
|
||||
/// clamp only means the largest source doesn't finish its items within one rotation before the
|
||||
/// reseed; the weight ratio itself is unaffected.
|
||||
/// </summary>
|
||||
private const int MaxCycleLength = 100_000;
|
||||
|
||||
/// <summary>
|
||||
/// Bounds the avoid-an-immediate-repeat retry when a rotation wraps. Unlike
|
||||
/// <see cref="ShuffleInOrderCollectionEnumerator" />, whose reshuffle randomizes which item leads the
|
||||
/// next cycle, this order's lead item is decided by weight — the heaviest source always wins the first
|
||||
/// pick. So when that source has a single item the lead never changes and an unbounded retry would spin
|
||||
/// forever. Avoiding a back-to-back repeat is a nicety; never terminating is not.
|
||||
/// </summary>
|
||||
private const int MaxReshuffleAttempts = 10;
|
||||
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
private readonly IList<CollectionWithItems> _collections;
|
||||
private readonly Lazy<Option<TimeSpan>> _lazyMinimumDuration;
|
||||
private Random _random;
|
||||
private MediaItem[] _shuffled;
|
||||
|
||||
public WeightedShuffleCollectionEnumerator(
|
||||
IList<CollectionWithItems> collections,
|
||||
CollectionEnumeratorState state,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
CurrentIncludeInProgramGuide = Option<bool>.None;
|
||||
|
||||
_collections = collections;
|
||||
_cancellationToken = cancellationToken;
|
||||
|
||||
int cycleLength = CycleLength(collections);
|
||||
if (cycleLength > 0 && state.Index >= cycleLength)
|
||||
{
|
||||
state.Index = 0;
|
||||
state.Seed = new Random(state.Seed).Next();
|
||||
}
|
||||
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
// computed over every source's items, not the current rotation: a rotation can be a strict subset
|
||||
// (see MaxCycleLength) and is rebuilt on every wrap, so caching over it would go stale
|
||||
_lazyMinimumDuration =
|
||||
new Lazy<Option<TimeSpan>>(() =>
|
||||
_collections
|
||||
.Bind(c => c.MediaItems)
|
||||
.Bind(i => i.GetNonZeroDuration())
|
||||
.OrderBy(identity)
|
||||
.HeadOrNone());
|
||||
|
||||
State = new CollectionEnumeratorState { Seed = state.Seed };
|
||||
while (State.Index < state.Index)
|
||||
{
|
||||
MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
}
|
||||
|
||||
public void ResetState(CollectionEnumeratorState state)
|
||||
{
|
||||
// only rebuild if needed
|
||||
if (State.Seed != state.Seed)
|
||||
{
|
||||
_random = new Random(state.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
}
|
||||
|
||||
State.Seed = state.Seed;
|
||||
State.Index = state.Index;
|
||||
}
|
||||
|
||||
public string SchedulingContextName => "Weighted Shuffle";
|
||||
|
||||
public CollectionEnumeratorState State { get; }
|
||||
|
||||
public Option<MediaItem> Current => _shuffled.Length != 0 ? _shuffled[State.Index % _shuffled.Length] : None;
|
||||
|
||||
public Option<bool> CurrentIncludeInProgramGuide { get; }
|
||||
|
||||
public void MoveNext(Option<DateTimeOffset> scheduledAt)
|
||||
{
|
||||
if (_shuffled.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((State.Index + 1) % _shuffled.Length == 0)
|
||||
{
|
||||
Option<MediaItem> tail = Current;
|
||||
|
||||
State.Index = 0;
|
||||
var attempts = 0;
|
||||
do
|
||||
{
|
||||
State.Seed = _random.Next();
|
||||
_random = new Random(State.Seed);
|
||||
_shuffled = Build(_collections, _random);
|
||||
attempts++;
|
||||
// guard on the rotation, not the raw collection count: an empty source contributes nothing,
|
||||
// so a 2-collection/1-item rotation would otherwise burn every attempt chasing an
|
||||
// impossible non-repeat on every wrap
|
||||
} while (!_cancellationToken.IsCancellationRequested && _shuffled.Length > 1 &&
|
||||
attempts < MaxReshuffleAttempts &&
|
||||
Current.Map(x => x.Id) == tail.Map(x => x.Id));
|
||||
}
|
||||
else
|
||||
{
|
||||
State.Index++;
|
||||
}
|
||||
|
||||
if (_shuffled.Length > 0)
|
||||
{
|
||||
State.Index %= _shuffled.Length;
|
||||
}
|
||||
}
|
||||
|
||||
public Option<TimeSpan> MinimumDuration => _lazyMinimumDuration.Value;
|
||||
|
||||
public int Count => _shuffled.Length;
|
||||
|
||||
/// <summary>
|
||||
/// Length of one rotation: enough picks for the source that needs the most of them to work through
|
||||
/// all of its items at its share of the rotation. Sources needing fewer picks loop within the
|
||||
/// rotation — that looping is exactly what makes equal weights mean equal airtime.
|
||||
/// </summary>
|
||||
private static int CycleLength(IList<CollectionWithItems> collections)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
long totalWeight = active.Sum(c => (long)EffectiveWeight(c));
|
||||
|
||||
var length = 0;
|
||||
foreach (CollectionWithItems collection in active)
|
||||
{
|
||||
// picks needed for this source to emit every item once, given it wins Weight of every totalWeight picks
|
||||
var needed = (int)Math.Min(
|
||||
MaxCycleLength,
|
||||
Math.Ceiling(collection.MediaItems.Count * (double)totalWeight / EffectiveWeight(collection)));
|
||||
length = Math.Max(length, needed);
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
private static List<CollectionWithItems> ActiveSources(IList<CollectionWithItems> collections) =>
|
||||
collections.Filter(c => c.MediaItems.Count > 0).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Weight as the rotation should treat it. The write path bounds this (<see cref="MultiCollectionItemWeight" />),
|
||||
/// but a row can predate that gate, so it is clamped rather than trusted: an out-of-range value must not
|
||||
/// drop a source from the channel (0 or negative) or overflow the weight sum inside a playout build.
|
||||
/// </summary>
|
||||
private static int EffectiveWeight(CollectionWithItems collection) =>
|
||||
Math.Clamp(collection.Weight, MultiCollectionItemWeight.Minimum, MultiCollectionItemWeight.Maximum);
|
||||
|
||||
private static MediaItem[] Build(IList<CollectionWithItems> collections, Random random)
|
||||
{
|
||||
List<CollectionWithItems> active = ActiveSources(collections);
|
||||
if (active.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var sources = active
|
||||
.Map(c => new WeightedSource { Weight = EffectiveWeight(c), Items = OrderItems(c, random) })
|
||||
.ToList();
|
||||
|
||||
int totalWeight = sources.Sum(s => s.Weight);
|
||||
int cycleLength = CycleLength(collections);
|
||||
|
||||
var result = new List<MediaItem>(cycleLength);
|
||||
for (var i = 0; i < cycleLength; i++)
|
||||
{
|
||||
// smooth weighted round-robin: every source gains its weight, the richest wins and pays the total.
|
||||
// Strict '>' keeps ties on the earliest source in list order, which makes the sequence deterministic.
|
||||
WeightedSource pick = null;
|
||||
foreach (WeightedSource source in sources)
|
||||
{
|
||||
source.Accumulator += source.Weight;
|
||||
if (pick is null || source.Accumulator > pick.Accumulator)
|
||||
{
|
||||
pick = source;
|
||||
}
|
||||
}
|
||||
|
||||
pick.Accumulator -= totalWeight;
|
||||
result.Add(pick.Items[pick.Cursor % pick.Items.Length]);
|
||||
pick.Cursor++;
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static MediaItem[] OrderItems(CollectionWithItems collectionWithItems, Random random)
|
||||
{
|
||||
// A custom-ordered collection is an explicit user sequence, so it is honored rather than shuffled
|
||||
// (same rule as ShuffleInOrderCollectionEnumerator). Otherwise the source's items are shuffled per
|
||||
// rotation, which is what makes the reseed on wrap produce a different rotation.
|
||||
if (collectionWithItems.UseCustomOrder)
|
||||
{
|
||||
return collectionWithItems.MediaItems.ToArray();
|
||||
}
|
||||
|
||||
return Shuffle(collectionWithItems.MediaItems, random);
|
||||
}
|
||||
|
||||
private static MediaItem[] Shuffle(IEnumerable<MediaItem> list, Random random)
|
||||
{
|
||||
MediaItem[] copy = list.ToArray();
|
||||
|
||||
int n = copy.Length;
|
||||
while (n > 1)
|
||||
{
|
||||
n--;
|
||||
int k = random.Next(n + 1);
|
||||
(copy[k], copy[n]) = (copy[n], copy[k]);
|
||||
}
|
||||
|
||||
return copy;
|
||||
}
|
||||
|
||||
private class WeightedSource
|
||||
{
|
||||
public int Weight { get; init; }
|
||||
public MediaItem[] Items { get; init; }
|
||||
public int Cursor { get; set; }
|
||||
public int Accumulator { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
break;
|
||||
|
||||
// playlist is handled later
|
||||
// playlist is handled later
|
||||
}
|
||||
|
||||
_mediaItems[content.Key] = items;
|
||||
@@ -178,7 +178,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
switch (Enum.Parse<PlaybackOrder>(content.Order, true))
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
switch (parsedOrder)
|
||||
{
|
||||
case PlaybackOrder.Chronological:
|
||||
return new ChronologicalMediaCollectionEnumerator(items, state);
|
||||
@@ -190,6 +191,13 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
return new BlockPlayoutShuffledMediaCollectionEnumerator(groupedMediaItems, state);
|
||||
}
|
||||
|
||||
// this path schedules nothing for the content, which is indistinguishable from "no items" downstream.
|
||||
// Orders are addressed by name here, so any order the enum knows parses fine and then lands here --
|
||||
// say so, rather than leaving an empty schedule to be explained (#70).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by sequential (YAML) scheduling; no content will be scheduled for this entry",
|
||||
parsedOrder);
|
||||
|
||||
return Option<IMediaCollectionEnumerator>.None;
|
||||
}
|
||||
|
||||
|
||||
Generated
+7250
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiCollectionItemWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1806,6 +1806,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "CollectionId");
|
||||
|
||||
b.HasIndex("CollectionId");
|
||||
@@ -1827,6 +1832,11 @@ namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "SmartCollectionId");
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
|
||||
Generated
+7075
File diff suppressed because it is too large
Load Diff
+40
@@ -0,0 +1,40 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMultiCollectionItemWeight : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionSmartItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Weight",
|
||||
table: "MultiCollectionItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1729,6 +1729,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "CollectionId");
|
||||
|
||||
b.HasIndex("CollectionId");
|
||||
@@ -1750,6 +1755,11 @@ namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
b.Property<bool>("ScheduleAsGroup")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Weight")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.HasKey("MultiCollectionId", "SmartCollectionId");
|
||||
|
||||
b.HasIndex("SmartCollectionId");
|
||||
|
||||
+19
-3
@@ -1,4 +1,4 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
@@ -30,7 +30,16 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
|
||||
.WithMany(mc => mc.MultiCollectionItems)
|
||||
.HasForeignKey(mci => mci.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId }));
|
||||
j =>
|
||||
{
|
||||
j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId });
|
||||
|
||||
// default 1 = fair share (#70). Must be a DB default too, or existing rows migrate to 0 --
|
||||
// a value that means nothing on a share-of-airtime scale. The enumerator clamps such a
|
||||
// row to the floor, so it rotates rather than vanishing; the backfill should still be
|
||||
// right at the source.
|
||||
j.Property(mci => mci.Weight).HasDefaultValue(1);
|
||||
});
|
||||
|
||||
builder.HasMany(m => m.SmartCollections)
|
||||
.WithMany(m => m.MultiCollections)
|
||||
@@ -43,6 +52,13 @@ public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollec
|
||||
.WithMany(mc => mc.MultiCollectionSmartItems)
|
||||
.HasForeignKey(mci => mci.MultiCollectionId)
|
||||
.OnDelete(DeleteBehavior.Cascade),
|
||||
j => j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId }));
|
||||
j =>
|
||||
{
|
||||
j.HasKey(mci => new { mci.MultiCollectionId, mci.SmartCollectionId });
|
||||
|
||||
// mirrors MultiCollectionItem above -- omitting it would silently un-weight
|
||||
// every smart-collection member of a multi collection
|
||||
j.Property(mci => mci.Weight).HasDefaultValue(1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -738,7 +738,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
sortedItems,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder,
|
||||
multiCollectionItem.Weight));
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -751,7 +752,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
items,
|
||||
multiCollectionItem.ScheduleAsGroup,
|
||||
multiCollectionItem.PlaybackOrder,
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder));
|
||||
multiCollectionItem.Collection.UseCustomPlaybackOrder,
|
||||
multiCollectionItem.Weight));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,7 +771,8 @@ public class MediaCollectionRepository : IMediaCollectionRepository
|
||||
items,
|
||||
multiCollectionSmartItem.ScheduleAsGroup,
|
||||
multiCollectionSmartItem.PlaybackOrder,
|
||||
false));
|
||||
false,
|
||||
multiCollectionSmartItem.Weight));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -350,6 +350,47 @@ public class CreateChannelFromLineupHandlerTests
|
||||
LeftOf(result).Value.ShouldContain("Invalid playback order for multi collection");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_WeightedShuffle_For_A_Multi_Item_Lineup()
|
||||
{
|
||||
// regression (#70, found by adversarial review of PR #402): a 2+ entry lineup is persisted as a
|
||||
// Playlist, and PlaylistEnumerator has no default arm -- an order it doesn't know leaves the
|
||||
// enumerator null and the items vanish from the playlist with nothing reported. This handler is the
|
||||
// THIRD writer of PlaylistItem.PlaybackOrder and was missed when the other two were gated.
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedCollection(21);
|
||||
await SeedCollection(22);
|
||||
|
||||
var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.WeightedShuffle);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeRequest(advanced: advanced, lineup: [CollectionItem(21), CollectionItem(22)]),
|
||||
CancellationToken.None);
|
||||
|
||||
LeftOf(result).Value.ShouldContain("not supported for a multi-item lineup");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Allow_WeightedShuffle_For_A_MultiCollection_Lineup()
|
||||
{
|
||||
// the mirror of PlayoutModeMustBeValid: a multi collection is WeightedShuffle's intended home, and a
|
||||
// single-entry lineup goes to a flood item on the Classic engine, which handles the order
|
||||
await SeedTemplateDependencies();
|
||||
await SeedTemplate();
|
||||
await SeedMultiCollection(16);
|
||||
|
||||
var advanced = new CreateChannelFromLineupAdvancedOptions(PlaybackOrder: PlaybackOrder.WeightedShuffle);
|
||||
|
||||
Either<BaseError, CreateChannelFromLineupResponseModel> result =
|
||||
await MakeHandler().Handle(
|
||||
MakeRequest(advanced: advanced, lineup: [MultiCollectionItem(16)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Mirror_Playout_Source()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the per-source <c>Weight</c> bounds (#70) on BOTH write paths, driven from one shared case list.
|
||||
/// <para>
|
||||
/// The bug this guards against was create and update <i>disagreeing</i>, not either one being wrong on its
|
||||
/// own: EF's <c>HasDefaultValue(1)</c> substitutes 1 for a <c>0</c> on INSERT (0 reads as "not set") while an
|
||||
/// UPDATE writes the 0 through, so the same request body landed differently depending on the verb. Testing the
|
||||
/// two paths against the same inputs is the point — a per-path test would have passed while they diverged.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MultiCollectionWeightValidationTests : MediaCollectionHandlerTestBase
|
||||
{
|
||||
private const int CollectionId = 1;
|
||||
private const int SmartCollectionId = 2;
|
||||
|
||||
private static readonly int[] RejectedWeights = [0, -1, -5, MultiCollectionItemWeight.Maximum + 1, int.MaxValue];
|
||||
private static readonly int[] AcceptedWeights =
|
||||
[MultiCollectionItemWeight.Minimum, 2, 500, MultiCollectionItemWeight.Maximum];
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
await SeedCollection(CollectionId);
|
||||
await SeedSmartCollection(SmartCollectionId);
|
||||
}
|
||||
|
||||
private CreateMultiCollectionHandler CreateHandler() => new(Db.Factory, SearchTargets);
|
||||
|
||||
private UpdateMultiCollectionHandler UpdateHandler()
|
||||
{
|
||||
var repo = Substitute.For<IMediaCollectionRepository>();
|
||||
|
||||
// the handler enqueues a playout rebuild for every playout using the collection once a real change
|
||||
// lands; an unstubbed mock returns null there and the NRE looks like a product bug
|
||||
repo.PlayoutIdsUsingMultiCollection(Arg.Any<int>()).Returns([]);
|
||||
|
||||
return new UpdateMultiCollectionHandler(Db.Factory, repo, Worker, SearchTargets);
|
||||
}
|
||||
|
||||
private static CreateMultiCollection CreateCommand(string name, int weight) =>
|
||||
new(name, [new CreateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]);
|
||||
|
||||
private static CreateMultiCollection CreateSmartCommand(string name, int weight) =>
|
||||
new(
|
||||
name,
|
||||
[new CreateMultiCollectionItem(null, SmartCollectionId, true, PlaybackOrder.WeightedShuffle, weight)]);
|
||||
|
||||
private async Task<int> SeedMultiCollection(string name)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> created =
|
||||
await CreateHandler().Handle(CreateCommand(name, MultiCollectionItemWeight.Minimum), CancellationToken.None);
|
||||
created.IsRight.ShouldBeTrue();
|
||||
return created.RightToSeq().Head().Id;
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Rejects_Out_Of_Range_Weight([ValueSource(nameof(RejectedWeights))] int weight)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await CreateHandler().Handle(CreateCommand($"create-{weight}", weight), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue($"weight {weight} must be rejected");
|
||||
result.LeftToSeq().Head().Value.ShouldContain("Weight must be between");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Rejects_Out_Of_Range_Weight([ValueSource(nameof(RejectedWeights))] int weight)
|
||||
{
|
||||
// the path that actually persisted a 0 before #402 -- EF's INSERT default masked it on create
|
||||
int id = await SeedMultiCollection($"update-{weight}");
|
||||
|
||||
Either<BaseError, Unit> result = await UpdateHandler().Handle(
|
||||
new UpdateMultiCollection(
|
||||
id,
|
||||
$"update-{weight}",
|
||||
[new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue($"weight {weight} must be rejected");
|
||||
result.LeftToSeq().Head().Value.ShouldContain("Weight must be between");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Accepts_In_Range_Weight([ValueSource(nameof(AcceptedWeights))] int weight)
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await CreateHandler().Handle(CreateCommand($"ok-create-{weight}", weight), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue($"weight {weight} must be accepted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Accepts_In_Range_Weight([ValueSource(nameof(AcceptedWeights))] int weight)
|
||||
{
|
||||
int id = await SeedMultiCollection($"ok-update-{weight}");
|
||||
|
||||
Either<BaseError, Unit> result = await UpdateHandler().Handle(
|
||||
new UpdateMultiCollection(
|
||||
id,
|
||||
$"ok-update-{weight}",
|
||||
[new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue($"weight {weight} must be accepted");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Rejects_Out_Of_Range_Weight_On_A_Smart_Item()
|
||||
{
|
||||
// the smart mirror is the half that gets forgotten; the gate must cover it too
|
||||
Either<BaseError, MultiCollectionViewModel> result =
|
||||
await CreateHandler().Handle(CreateSmartCommand("smart-bad", 0), CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
result.LeftToSeq().Head().Value.ShouldContain("Weight must be between");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_And_Create_Agree_On_Every_Case()
|
||||
{
|
||||
// the regression that started B2: same input, different verb, different outcome. Asserted directly
|
||||
// rather than inferred from the two suites above passing.
|
||||
foreach (int weight in RejectedWeights.Concat(AcceptedWeights))
|
||||
{
|
||||
Either<BaseError, MultiCollectionViewModel> created = await CreateHandler()
|
||||
.Handle(CreateCommand($"agree-{weight}", weight), CancellationToken.None);
|
||||
|
||||
int id = await SeedMultiCollection($"agree-base-{weight}");
|
||||
Either<BaseError, Unit> updated = await UpdateHandler().Handle(
|
||||
new UpdateMultiCollection(
|
||||
id,
|
||||
$"agree-base-{weight}",
|
||||
[new UpdateMultiCollectionItem(CollectionId, null, true, PlaybackOrder.WeightedShuffle, weight)]),
|
||||
CancellationToken.None);
|
||||
|
||||
updated.IsRight.ShouldBe(
|
||||
created.IsRight,
|
||||
$"create and update disagreed on weight {weight}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Infrastructure.Data.Repositories;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that a multi collection's per-source <c>Weight</c> (#70) reaches the scheduler for BOTH kinds of
|
||||
/// member. A multi collection holds plain collections and smart collections through two separate join
|
||||
/// entities, and only the plain one is obvious — forget the smart mirror and smart-collection members
|
||||
/// silently fall back to fair-share with nothing reporting it.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class MediaCollectionRepositoryWeightTests
|
||||
{
|
||||
private InMemoryTvContext _db;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private static Movie Movie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
MovieMetadata = [],
|
||||
MediaVersions = []
|
||||
};
|
||||
|
||||
[Test]
|
||||
public async Task Weight_Is_Carried_For_Both_Collection_And_SmartCollection_Members()
|
||||
{
|
||||
const int MultiCollectionId = 1;
|
||||
const int CollectionId = 10;
|
||||
const int SmartCollectionId = 20;
|
||||
|
||||
await using (TvContext context = _db.Factory.CreateDbContext())
|
||||
{
|
||||
context.Movies.Add(Movie(100));
|
||||
|
||||
context.Collections.Add(
|
||||
new Collection
|
||||
{
|
||||
Id = CollectionId,
|
||||
Name = "plain",
|
||||
UseCustomPlaybackOrder = false,
|
||||
CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 100 }]
|
||||
});
|
||||
|
||||
context.SmartCollections.Add(
|
||||
new SmartCollection
|
||||
{
|
||||
Id = SmartCollectionId,
|
||||
Name = "smart",
|
||||
Query = "type:movie"
|
||||
});
|
||||
|
||||
context.MultiCollections.Add(
|
||||
new MultiCollection
|
||||
{
|
||||
Id = MultiCollectionId,
|
||||
Name = "multi",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
MultiCollectionId = MultiCollectionId,
|
||||
CollectionId = CollectionId,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 5
|
||||
}
|
||||
],
|
||||
MultiCollectionSmartItems =
|
||||
[
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
MultiCollectionId = MultiCollectionId,
|
||||
SmartCollectionId = SmartCollectionId,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 7
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (TvContext context = _db.Factory.CreateDbContext())
|
||||
{
|
||||
MultiCollection multiCollection = context.MultiCollections
|
||||
.Single(mc => mc.Id == MultiCollectionId);
|
||||
|
||||
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load();
|
||||
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load();
|
||||
|
||||
// the persisted weights must survive the round trip on BOTH join entities
|
||||
multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(5);
|
||||
multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(7);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Weight_Defaults_To_One_So_An_Existing_Row_Is_Fair_Share_Not_Dropped()
|
||||
{
|
||||
// the migration backfills 1 rather than 0, so a pre-existing member arrives as fair-share rather than
|
||||
// carrying a weight that means nothing on a share-of-airtime scale. (The enumerator also clamps, so a
|
||||
// 0 would rotate at the floor rather than vanish — but the backfill should be right at the source.)
|
||||
const int MultiCollectionId = 2;
|
||||
const int CollectionId = 30;
|
||||
const int SmartCollectionId = 40;
|
||||
|
||||
await using (TvContext context = _db.Factory.CreateDbContext())
|
||||
{
|
||||
context.Collections.Add(new Collection { Id = CollectionId, Name = "plain2", CollectionItems = [] });
|
||||
context.SmartCollections.Add(
|
||||
new SmartCollection { Id = SmartCollectionId, Name = "smart2", Query = "type:movie" });
|
||||
|
||||
context.MultiCollections.Add(
|
||||
new MultiCollection
|
||||
{
|
||||
Id = MultiCollectionId,
|
||||
Name = "multi2",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
MultiCollectionId = MultiCollectionId,
|
||||
CollectionId = CollectionId,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
],
|
||||
MultiCollectionSmartItems =
|
||||
[
|
||||
new MultiCollectionSmartItem
|
||||
{
|
||||
MultiCollectionId = MultiCollectionId,
|
||||
SmartCollectionId = SmartCollectionId,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.Shuffle
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
await using (TvContext context = _db.Factory.CreateDbContext())
|
||||
{
|
||||
MultiCollection multiCollection = context.MultiCollections.Single(mc => mc.Id == MultiCollectionId);
|
||||
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionItems).Load();
|
||||
context.Entry(multiCollection).Collection(mc => mc.MultiCollectionSmartItems).Load();
|
||||
|
||||
multiCollection.MultiCollectionItems.Single().Weight.ShouldBe(1);
|
||||
multiCollection.MultiCollectionSmartItems.Single().Weight.ShouldBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetMultiCollectionCollections_Carries_Weight_Into_CollectionWithItems()
|
||||
{
|
||||
// the mapping tests above prove the column round-trips; this proves the repository actually hands the
|
||||
// weight to the scheduler. The plain-collection branch and the smart-collection branch build
|
||||
// CollectionWithItems at separate call sites, so each has to be checked on its own.
|
||||
const int MultiCollectionId = 3;
|
||||
const int CollectionId = 50;
|
||||
|
||||
await using (TvContext context = _db.Factory.CreateDbContext())
|
||||
{
|
||||
context.Movies.Add(Movie(500));
|
||||
|
||||
context.Collections.Add(
|
||||
new Collection
|
||||
{
|
||||
Id = CollectionId,
|
||||
Name = "weighted-plain",
|
||||
UseCustomPlaybackOrder = false,
|
||||
CollectionItems = [new CollectionItem { CollectionId = CollectionId, MediaItemId = 500 }]
|
||||
});
|
||||
|
||||
context.MultiCollections.Add(
|
||||
new MultiCollection
|
||||
{
|
||||
Id = MultiCollectionId,
|
||||
Name = "weighted-multi",
|
||||
MultiCollectionItems =
|
||||
[
|
||||
new MultiCollectionItem
|
||||
{
|
||||
MultiCollectionId = MultiCollectionId,
|
||||
CollectionId = CollectionId,
|
||||
ScheduleAsGroup = true,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
Weight = 4
|
||||
}
|
||||
],
|
||||
MultiCollectionSmartItems = []
|
||||
});
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var repository = new MediaCollectionRepository(Substitute.For<ISearchIndex>(), _db.Factory);
|
||||
|
||||
List<CollectionWithItems> result =
|
||||
await repository.GetMultiCollectionCollections(MultiCollectionId, CancellationToken.None);
|
||||
|
||||
CollectionWithItems collection = result.ShouldHaveSingleItem();
|
||||
collection.Weight.ShouldBe(4);
|
||||
}
|
||||
}
|
||||
@@ -136,13 +136,15 @@ public class MultiCollectionController(IMediator mediator) : ControllerBase
|
||||
null,
|
||||
i.Collection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
i.PlaybackOrder,
|
||||
i.Weight))
|
||||
.Concat(vm.SmartItems.Map(i => new MultiCollectionItemResponseModel(
|
||||
null,
|
||||
i.SmartCollection.Id,
|
||||
i.SmartCollection.Name,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder)))
|
||||
i.PlaybackOrder,
|
||||
i.Weight)))
|
||||
.ToList();
|
||||
|
||||
return new MultiCollectionResponseModel(vm.Id, vm.Name, items);
|
||||
|
||||
@@ -9,7 +9,8 @@ public record MultiCollectionItemRequest(
|
||||
int? CollectionId,
|
||||
int? SmartCollectionId,
|
||||
bool ScheduleAsGroup,
|
||||
PlaybackOrder PlaybackOrder);
|
||||
PlaybackOrder PlaybackOrder,
|
||||
int Weight = 1);
|
||||
|
||||
public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItemRequest> Items)
|
||||
{
|
||||
@@ -21,6 +22,7 @@ public record CreateMultiCollectionRequest(string Name, List<MultiCollectionItem
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
i.PlaybackOrder,
|
||||
i.Weight))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ public record UpdateMultiCollectionRequest(string Name, List<MultiCollectionItem
|
||||
i.CollectionId,
|
||||
i.SmartCollectionId,
|
||||
i.ScheduleAsGroup,
|
||||
i.PlaybackOrder))
|
||||
i.PlaybackOrder,
|
||||
i.Weight))
|
||||
.ToList(),
|
||||
expectedVersions);
|
||||
}
|
||||
|
||||
@@ -27564,6 +27564,11 @@
|
||||
},
|
||||
"playbackOrder": {
|
||||
"$ref": "#/components/schemas/PlaybackOrder"
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -27599,6 +27604,11 @@
|
||||
},
|
||||
"playbackOrder": {
|
||||
"$ref": "#/components/schemas/PlaybackOrder"
|
||||
},
|
||||
"weight": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"default": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -27883,7 +27893,8 @@
|
||||
"MultiEpisodeShuffle",
|
||||
"SeasonEpisode",
|
||||
"RandomRotation",
|
||||
"Marathon"
|
||||
"Marathon",
|
||||
"WeightedShuffle"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -89,6 +89,7 @@ in-file entries.
|
||||
- [2026-07-17 — Auto-Tune DetailPanel member list = live search-index roll-up, not EF enumeration (#384)](#2026-07-17--auto-tune-detailpanel-member-list--live-search-index-roll-up-not-ef-enumeration-384)
|
||||
- [2026-07-17 — No persistent compiler servers in CI; every `services:` container gets an explicit cap; #390's small-lane move reversed (#406)](#2026-07-17--no-persistent-compiler-servers-in-ci-every-services-container-gets-an-explicit-cap-390s-small-lane-move-reversed-406)
|
||||
- [2026-07-17 — Channel health on the API = the raw `PlayoutCount` fact on the list DTO, not a derived status enum (#72)](#2026-07-17--channel-health-on-the-api--the-raw-playoutcount-fact-on-the-list-dto-not-a-derived-status-enum-72)
|
||||
- [2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)](#2026-07-17--weighted--fair-share-distribution-is-a-new-weightedshuffle-order-shuffleinorder-is-anti-clumping-not-fair-share-70)
|
||||
|
||||
---
|
||||
|
||||
@@ -1387,3 +1388,103 @@ Keeping all three out held #72 to a **read-path-only** change: no migration, no
|
||||
channel editor's playout-source guard. Both call sites now share `Mapper.GetPlayoutsCount` (Mirror-aware).
|
||||
The related *silent* server-side coercion of Mirror→Generated (a 200 that discards the caller's intent,
|
||||
against the §3 "surface it, don't silently filter" rule) is filed as **#401**, not fixed here.
|
||||
## 2026-07-17 — Weighted / fair-share distribution is a new `WeightedShuffle` order; `ShuffleInOrder` is anti-clumping, not fair-share (#70)
|
||||
|
||||
`PlaybackOrder.WeightedShuffle = 9` ships the two behaviors #70 asked for — "air Show A 70% / Show B 30%"
|
||||
and "no single show dominates" — as **one** order, because fair-share is the equal-weights case of weighted.
|
||||
|
||||
- **`ShuffleInOrder` does NOT already do fair-share, despite looking like it.** Its balanced shuffle
|
||||
(keyj) pads every source to the longest with `Option<MediaItem>.None` spacers — but **spacers emit
|
||||
nothing** (`ShuffleInOrderCollectionEnumerator.cs`, `result.AddRange(maybeItem)` over an `Option`). One
|
||||
cycle therefore plays **every item exactly once**, so a 200-episode show still takes 10× the airtime of a
|
||||
20-episode one. What it buys is **anti-clumping**: the small show is spread evenly instead of arriving in
|
||||
bursts, and each show plays chronologically. That is a different product from "airs as often". Recorded
|
||||
because the padding reads as equalization and this was misread once during #70's own design pass — the
|
||||
distinction is the entire justification for the issue.
|
||||
- **Plain `Shuffle` is already implicitly weighted by collection size** (Fisher–Yates over flattened items),
|
||||
which is why "just use Shuffle" isn't fair-share either.
|
||||
- **One new enum value, not two, and not a separate setting.** Fair-share = `WeightedShuffle` with weights
|
||||
left at their default of 1, so the UI's "fair-share toggle" needs no second mechanism. A separate
|
||||
non-enum "distribution" setting was rejected: it would add an orthogonal axis every `PlaybackOrder`
|
||||
dispatch site must *also* consult, multiplying the blast radius below. Retrofitting weights onto
|
||||
`ShuffleInOrder` was rejected: it would silently change shipped users' output.
|
||||
- **Weights live on `MultiCollectionItem` AND `MultiCollectionSmartItem`** (`int Weight`, DB default 1,
|
||||
dual-provider migration `AddMultiCollectionItemWeight`). The mirror is mandatory — omitting the smart
|
||||
item un-weights smart-collection members silently. The DB default must be **1, not 0**: a 0 backfill would
|
||||
hand every pre-existing member to the rotation carrying a weight that means nothing on a share-of-airtime
|
||||
scale. (The enumerator clamps such rows to the floor so they rotate fair-share rather than vanish — but the
|
||||
backfill should be right at the source.)
|
||||
Rejected carriers: `PlaylistItem` (sequential engine; its `Count`/`PlayAll` already mean "how much of this
|
||||
source", and drain-N-consecutively `A A A B` is a different product from smooth interleave `A A B A`);
|
||||
`CollectionItem` (wrong grain — per media item, and the largest table).
|
||||
- **Applied on the `ShuffleInOrder`-shaped path only, because that is where source identity survives.**
|
||||
`MultiCollectionGrouper` collapses sources into `GroupedMediaItem` + `.Distinct()`, so by the time the
|
||||
`Shuffle` path has its list, *which source an item came from* is gone. `CollectionWithItems` gains
|
||||
`int Weight = 1`; `WeightedShuffleCollectionEnumerator` consumes
|
||||
`ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder` **unchanged** — the schedule-entity-free entry
|
||||
point #380 reserved for this issue (no signature change, no god-factory, engines stay separate).
|
||||
- **Stateless.** Smooth weighted round-robin (`acc += weight`; richest wins; pays the total) is a pure
|
||||
function of (`Seed`, `Index`), so it restores by replay like its siblings and needs no per-source counters
|
||||
(`CollectionEnumeratorState` has nowhere to put them). A rotation is sized so the source needing the most
|
||||
picks works through all its items at its share; smaller sources loop inside it — that looping is what makes
|
||||
equal weights mean equal airtime. **Weight ratios are data, not state**: a `Reset` build re-randomizes
|
||||
`Playout.Seed` (fresh within-source shuffle and start point) but the ratios hold identically.
|
||||
- **Ties break to the earliest source in list order** (strict `>`), keeping the sequence deterministic.
|
||||
- **`ScheduleAsGroup` is not read by this order**, unlike `ShuffleInOrder`, which merges every
|
||||
non-`ScheduleAsGroup` collection into ONE source. Here each `CollectionWithItems` is its own weighted
|
||||
source, which is the entire point — per-source weights are meaningless if sources are merged. Recorded
|
||||
because it means a persisted per-item flag quietly has no effect under this order.
|
||||
- **Within-source order is shuffled, not chronological** (custom-ordered collections are still honored).
|
||||
Chronological inner order would make every rotation byte-identical, since source selection is
|
||||
deterministic — the reseed on wrap would change nothing and the avoid-an-immediate-repeat retry could
|
||||
never succeed. "Weighted **in order**" would be a separate order if wanted.
|
||||
- **Cross-engine exposure is closed by write-path validation, not by touching the silent fallbacks.** An
|
||||
unhandled `PlaybackOrder` fails **silently at five of six dispatch sites**: Classic substitutes
|
||||
`RandomizedMediaCollectionEnumerator` (`PlayoutBuilder`'s `default:`, `// TODO: handle this error case
|
||||
differently?`); `PlaylistEnumerator` has **no `default:` arm**, so the enumerator stays null and the item is
|
||||
dropped from the playlist (and null is *legitimate* there for `SeasonEpisode` with `Count == 0`, so nothing
|
||||
flags it); `BlockPlayoutBuilder` filters block items against an allow-list and `continue`s past the rest;
|
||||
YAML/Scripted return `Option.None`, which their callers read as "no content". Only `MultiCollectionGroup`
|
||||
throws. **A weighted order degrading to unweighted random is the worst case, because the output is supposed
|
||||
to look arbitrary.** So the write path **rejects** `WeightedShuffle` everywhere it isn't handled — if it
|
||||
can't be persisted where it isn't handled, the silent sites never see it — and YAML/Scripted (which address
|
||||
orders by *name*, so `Enum.Parse` accepts it regardless) log a warning when an order falls through
|
||||
unhandled. Making those pre-existing fallbacks loud is a real but separate defect class: **#403**, an
|
||||
explicit non-goal here.
|
||||
- **Exactly two writers persist a CALLER-SUPPLIED `PlaylistItem.PlaybackOrder`, and the second is not
|
||||
obvious.** `ReplacePlaylistItems` is the expected one; **`CreateChannelFromLineup`** is the other — a
|
||||
lineup of 2+ entries is stored as a `Playlist`, and its own guard only covered *MultiCollection* entries,
|
||||
so a multi-entry lineup of plain collections slipped `WeightedShuffle` straight through to
|
||||
`PlaylistEnumerator`'s null-drop. Both are gated. The full writer set, since "persisting writer" alone is
|
||||
the wrong axis: `Add*ToPlaylist` and `TraktCommandBase` **do** persist the field but hardcode it
|
||||
(`Shuffle`/`Chronological`), so no caller value reaches them; `ReplaceBlockItems` writes the *different*
|
||||
field `BlockItem.PlaybackOrder` (also gated); and `PreviewPlaylistPlayout`, `PreviewBlockPlayout` and
|
||||
`Engine/PlaylistHelper` build in memory without persisting.
|
||||
**This bullet has now been wrong three times, each time in the same shape**: it recorded the perimeter as
|
||||
complete when the gate covered only the writers already in hand (missing `CreateChannelFromLineup`); the
|
||||
first correction miscounted by conflating `PlaylistItem` with `BlockItem`; the second said "two persisting
|
||||
writers" when the true predicate is *two writers that persist a caller-supplied order*. The failure is
|
||||
always **enumerating from a list instead of re-deriving from a grep** — which is also how the
|
||||
"0-weight is filtered out" claims survived their own correction. Before trusting any "this order can't
|
||||
reach that engine" claim, grep every writer of **each field separately** and classify each as
|
||||
persists-caller-value / persists-hardcoded / in-memory. The non-obvious composite handler is the one that
|
||||
gets missed.
|
||||
- **Weight is bounded at the write path** (`MultiCollectionItemWeight`, 1..1000) and clamped again in the
|
||||
enumerator (`EffectiveWeight`). They do **different** jobs, which is why both stay: **EF's
|
||||
`HasDefaultValue(1)` substitutes 1 for a `0` on INSERT** (0 reads as "not set") **but an UPDATE writes the
|
||||
0 through**, so create and update disagreed on the same input — the gate makes them agree and refuses
|
||||
values that mean nothing on a share-of-airtime scale (0, negative, or absurdly large). The **clamp** is
|
||||
what makes the rotation arithmetic safe for rows that predate the gate: it bounds every weight before the
|
||||
sum, so `Sum(weights)` cannot overflow regardless of what is stored. Historical note: an earlier revision
|
||||
of this PR *filtered* `Weight > 0` instead of clamping, which silently deleted a 0-weight source from the
|
||||
channel; the clamp replaced it, and any comment still describing that filter is stale.
|
||||
- **Weight is returned on the multi-collection GET, not just accepted on write.** The update replaces the
|
||||
item list, so a client that reads, edits a name, and writes back would silently reset every weight to the
|
||||
default if the read didn't carry it. Edits ride the existing `Version` token (If-Match/412, §7a).
|
||||
- **Scope:** Classic engine only; no SPA (the schedule-editor weight UI is gated on #388). Plain
|
||||
single/smart collections (the `GroupIntoFakeCollections` path) carry no persisted weights — fair-share
|
||||
applies there by construction, and per-source weighting requires a multi collection. Auto-tune's
|
||||
per-member weights (#385/#386) need their own carrier: an auto-tuned channel is backed by one live
|
||||
SmartCollection, so its "sources" are members of a single collection, and a `PlaylistItem` cannot even
|
||||
reference a search query. Nothing here forecloses it — the enumerator reads weights off
|
||||
`CollectionWithItems` source-agnostically.
|
||||
|
||||
@@ -66,10 +66,11 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **FillerPreset** | A reusable filler definition: `FillerKind` (PreRoll/MidRoll/PostRoll/Tail/Fallback; also `GuideMode=99`, `DecoDefault=100`) × `FillerMode` (None/Duration/Count/Pad/RandomCount) over a collection/media-item/multi-collection/smart-collection/playlist source, with an optional `Expression` (NCalc). Referenced from `ProgramScheduleItem` (Pre/Mid/Post/Tail/FallbackFillerId) and `Channel.FallbackFillerId`. | `FillerPreset`, `FillerKind`, `FillerMode` | `/app/filler-presets` |
|
||||
| **Clock-boundary padding** (#77) | Snapping a schedule to clean `:00/:15/:30`-style guide times is **not a separate feature** — it is `FillerMode.Pad` + `PadToNearestMinute` on a `FillerPreset` (Classic), or the `pad_to_next`/`pad_until` YAML instructions (Sequential). Block playouts are inherently clock-anchored via `TemplateItem.StartTime`. The EPG reflects the padded boundary automatically (`ChannelGuideProjector` coalesces trailing filler into the programme window). No one-click per-channel toggle yet — deferred to a UI follow-up blocked on #388. See `decisions.md` 2026-07-17. | `FillerPreset` (`Pad`) | `/app/filler-presets` |
|
||||
| **Seasonal / date-conditional scheduling** (#73) | Holiday/seasonal channels are **not a separate feature** — they are the existing date predicate on `IAlternateScheduleItem`, implemented by `ProgramScheduleAlternate` (Classic) and `PlayoutTemplate` (Block), evaluated by `AlternateScheduleSelector.GetScheduleForDate` (first match by `Index`, catch-all last). **Leaving `StartYear`/`EndYear` empty makes the range repeat every year** — the "set once, works every December" switch; explicit years (required in pairs) mean a one-off window and disable wrap-around detection. Wrap-around (Nov→Feb) and invalid/leap dates (Feb 31) are handled. No *soft* prioritization primitive exists (binary first-match-wins); that ask belongs to #70's weighting work. See `channels.md` → "Recipe: seasonal / holiday programming" and `decisions.md` 2026-07-17. | `IAlternateScheduleItem`, `ProgramScheduleAlternate`, `PlayoutTemplate` | `/app/playouts/{id}/alternate-schedules`, `/app/playouts/{id}/templates` |
|
||||
| **Playback order** | How a schedule item's source(s) are sequenced (`PlaybackOrder`). Note three that are easily confused: **`Shuffle`** is Fisher–Yates over the flattened items, so airtime is implicitly proportional to collection size (a 200-episode show swamps a 20-episode one). **`ShuffleInOrder`** is a balanced shuffle (keyj) that pads sources to equal length with non-emitting spacers — it plays every item exactly once per cycle, so it prevents *clumping* but leaves airtime proportional to size; it is **not** fair-share. **`WeightedShuffle`** (#70) picks a *source* by smooth weighted round-robin then takes its next item, so each source's `Weight` is its share of airtime — equal weights (the default) mean equal airtime regardless of library size, with small sources looping. Classic engine only; rejected at the write path for playlist/block items. See `decisions.md` 2026-07-17. | `PlaybackOrder`, `MultiCollectionItem.Weight`, `MultiCollectionSmartItem.Weight` | `/app/schedules` (weight UI pending, gated on #388) |
|
||||
| **Watermark** | `ChannelWatermark` image overlay; attached at channel, schedule-item, block-item, deco, or playout-item level with position/size/opacity. | `ChannelWatermark`, `DecoWatermark`, `BlockItemWatermark`, `ProgramScheduleItemWatermark` | `/app/watermarks` |
|
||||
| **Collection** | Manual list of media items (`CollectionItem`). | `Collection` | `/app/collections` |
|
||||
| **SmartCollection** | Saved search — a `Query` string, no static item list. | `SmartCollection` | `/app/collections` |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). | `MultiCollection` | `/app/multi-collections` (#151) |
|
||||
| **MultiCollection** | Combines multiple `Collection`s and/or `SmartCollection`s (with grouping via `MultiCollectionItem`/`MultiCollectionSmartItem`). Both join entities carry a per-source `Weight` (default 1) used by `PlaybackOrder.WeightedShuffle` (#70) and ignored by every other order — the two are mirrors, so a change to one belongs on the other. | `MultiCollection` | `/app/multi-collections` (#151) |
|
||||
| **RerunCollection** | One source (collection/media item/multi/smart) with separate `FirstRunPlaybackOrder` vs `RerunPlaybackOrder`. | `RerunCollection` | `/app/rerun-collections` (#152) |
|
||||
| **Playlist** / **PlaylistGroup** | Ordered `PlaylistItem`s; `IsSystem` flag marks built-in/non-deletable playlists and groups. | `Playlist`, `PlaylistGroup`, `PlaylistItem` | `/app/playlists` |
|
||||
| **Media kinds** | `CollectionType` enum distinguishes container kinds (Collection/TelevisionShow/TelevisionSeason/Artist/MultiCollection/SmartCollection/Playlist/RerunFirstRun/RerunRerun/SearchQuery) from leaf media kinds (Movie/Episode/MusicVideo/OtherVideo/Song/Image/RemoteStream) plus synthetic `FakeCollection`/`FakePlaylistItem`. Concrete media entities: `Movie`, `Show`/`Season`/`Episode`, `Artist`/`MusicVideo`/`Song`, `OtherVideo`, `Image`, `RemoteStream` (`ErsatzTV.Core/Domain/MediaItem/`). | `MediaItem` subclasses | `/app/media?kind=...` |
|
||||
|
||||
Vendored
+3
-1
@@ -936,6 +936,7 @@ export interface components {
|
||||
"smartCollectionId": null | number;
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"weight"?: number;
|
||||
};
|
||||
"MultiCollectionItemResponseModel": {
|
||||
"collectionId": null | number;
|
||||
@@ -943,6 +944,7 @@ export interface components {
|
||||
"name": string;
|
||||
"scheduleAsGroup": boolean;
|
||||
"playbackOrder": components["schemas"]["PlaybackOrder"];
|
||||
"weight"?: number;
|
||||
};
|
||||
"MultiCollectionResponseModel": {
|
||||
"id": number;
|
||||
@@ -998,7 +1000,7 @@ export interface components {
|
||||
"remotePath": null | string;
|
||||
"localPath": null | string;
|
||||
};
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon";
|
||||
"PlaybackOrder": "None" | "Chronological" | "Random" | "Shuffle" | "ShuffleInOrder" | "MultiEpisodeShuffle" | "SeasonEpisode" | "RandomRotation" | "Marathon" | "WeightedShuffle";
|
||||
"PlaylistGroupResponseModel": {
|
||||
"id": number;
|
||||
"name": string;
|
||||
|
||||
Reference in New Issue
Block a user