Files
ersatztv/ErsatzTV.Infrastructure/Data/Configurations/Collection/MultiCollectionConfiguration.cs
T
68be319403 feat(70): WeightedShuffle playback order — weighted / fair-share distribution
Adds PlaybackOrder.WeightedShuffle = 9 (Classic engine only) plus the
per-source weight it distributes by.

Why a new order rather than making ShuffleInOrder weight-aware: ShuffleInOrder's
balanced shuffle pads sources to equal length with Option.None spacers, and
spacers emit nothing — so one cycle plays every item exactly once and airtime
stays proportional to collection size. It prevents *clumping*, not *domination*.
Retrofitting weights onto it would silently change shipped users' output.

WeightedShuffleCollectionEnumerator picks a source by smooth weighted
round-robin (acc += weight; richest wins; pays the total), then takes that
source's next item. Weights 3:1 emit A A B A; ties break to the earliest source
in list order. Fair-share is the equal-weights default, so one mechanism covers
both behaviors in the issue.

One rotation is sized so the source needing the most picks works through all its
items at its share; smaller sources loop within it — that looping is what makes
equal weights mean equal airtime regardless of library size. The sequence stays a
pure function of (Seed, Index), so it restores by replay like its siblings and
needs no per-source persisted counters.

It consumes ShuffleSourceBuilder.GetCollectionItemsForShuffleInOrder unchanged —
the schedule-entity-free entry point #380 reserved for this issue. Source
identity is destroyed on the Shuffle path (MultiCollectionGrouper collapses to
GroupedMediaItem + Distinct), so the weight is only reachable via
CollectionWithItems on the ShuffleInOrder-shaped path.

Weight is persisted on MultiCollectionItem AND MultiCollectionSmartItem — the
mirror is mandatory; omitting the smart item silently un-weights smart-collection
members. Both carry a DB default of 1: without it existing rows would migrate to
0, and a 0-weight source is dropped by the enumerator.

Refs #70

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:00:46 +00:00

63 lines
2.6 KiB
C#

using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class MultiCollectionConfiguration : IEntityTypeConfiguration<MultiCollection>
{
public void Configure(EntityTypeBuilder<MultiCollection> builder)
{
builder.ToTable("MultiCollection");
builder.Property(mc => mc.Version).IsConcurrencyToken();
builder.Property(mc => mc.Name)
.HasMaxLength(50)
.HasColumnType("varchar(50)");
builder.HasIndex(mc => mc.Name)
.IsUnique();
builder.HasMany(m => m.Collections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionItem>(
j => j.HasOne(mci => mci.Collection)
.WithMany(c => c.MultiCollectionItems)
.HasForeignKey(mci => mci.CollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(mci => mci.MultiCollection)
.WithMany(mc => mc.MultiCollectionItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j =>
{
j.HasKey(mci => new { mci.MultiCollectionId, mci.CollectionId });
// default 1 = fair share (#70). Must be a DB default too: existing rows would
// otherwise migrate to 0, and a 0-weight source is dropped by the enumerator.
j.Property(mci => mci.Weight).HasDefaultValue(1);
});
builder.HasMany(m => m.SmartCollections)
.WithMany(m => m.MultiCollections)
.UsingEntity<MultiCollectionSmartItem>(
j => j.HasOne(mci => mci.SmartCollection)
.WithMany(c => c.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.SmartCollectionId)
.OnDelete(DeleteBehavior.Cascade),
j => j.HasOne(mci => mci.MultiCollection)
.WithMany(mc => mc.MultiCollectionSmartItems)
.HasForeignKey(mci => mci.MultiCollectionId)
.OnDelete(DeleteBehavior.Cascade),
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);
});
}
}