Adversarial review of PR #402 returned BLOCKED. It could not break the WRR math or the stateless-restore claim (it probed restore across wraps at indices 12/13/20/37 — all held, and the clamp preserves a 1000:1 ratio exactly). What it broke was the perimeter. B1 — the validation gate had a hole, so the silent-drop bug shipped. CreateChannelFromLineup is a THIRD writer of PlaylistItem.PlaybackOrder; its own guard only covered MultiCollection entries, so a 2+ entry lineup of plain collections persisted WeightedShuffle straight through to PlaylistEnumerator's null-drop. My decisions.md claim that "the silent sites never see it" was false as written — corrected in place, with the lesson recorded: grep every writer of the field, the non-obvious composite handler is the one that gets missed. The Add*ToPlaylist handlers are safe only because they hardcode their order. B2 — Weight had no validation at all, and create/update disagreed on the same input. EF's HasDefaultValue(1) substitutes 1 for a 0 on INSERT (0 reads as "not set") but an UPDATE writes the 0 through — and a 0-weight source was filtered out of the rotation, deleting it from the channel silently. Exactly the failure this order is careful to avoid everywhere else. Now bounded 1..1000 by a shared MultiCollectionItemWeight used by both paths so they cannot drift, and clamped again in the enumerator for rows that predate the gate. B3 — Sum(weights) is checked arithmetic, so two int.MaxValue weights threw OverflowException from inside a playout build. Reachable through the API precisely because of B2. The ceiling fixes both; the sum also widens to long. M1 the lineup mirror now allows WeightedShuffle for multi collections, matching the PlayoutModeMustBeValid change it claims to mirror. M3 ScheduleAsGroup is documented as deliberately unread by this order. L1 MinimumDuration is computed over every source instead of the current rotation — under the clamp a rotation is a strict subset and is rebuilt each wrap, so caching over it went stale. L2 the retry guard keys off the rotation, not the raw collection count. N1 the tautological default test is gone: it built entities in C#, so it asserted the property initializer, not the migration — it could not have failed. Replaced with clamp, overflow, and cross-wrap restore cases (the property the review proved but found unpinned). H1 the two follow-ups the PR body claimed were "filed" did not exist. Now filed: #403 (silent dispatch-fallback hardening) and #404 (SPA weight UI, blocked-by #388). Core.Tests 565 passed, ErsatzTV.Tests 1643 passed, 0 failed. Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
119 lines
4.8 KiB
C#
119 lines
4.8 KiB
C#
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Search;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using static ErsatzTV.Application.MediaCollections.Mapper;
|
|
|
|
namespace ErsatzTV.Application.MediaCollections;
|
|
|
|
public class CreateMultiCollectionHandler :
|
|
IRequestHandler<CreateMultiCollection, Either<BaseError, MultiCollectionViewModel>>
|
|
{
|
|
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
|
private readonly ISearchTargets _searchTargets;
|
|
|
|
public CreateMultiCollectionHandler(IDbContextFactory<TvContext> dbContextFactory, ISearchTargets searchTargets)
|
|
{
|
|
_dbContextFactory = dbContextFactory;
|
|
_searchTargets = searchTargets;
|
|
}
|
|
|
|
public async Task<Either<BaseError, MultiCollectionViewModel>> Handle(
|
|
CreateMultiCollection request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
Validation<BaseError, MultiCollection> validation = await Validate(dbContext, request);
|
|
return await validation.Apply(c => PersistCollection(dbContext, c));
|
|
}
|
|
|
|
private async Task<MultiCollectionViewModel> PersistCollection(
|
|
TvContext dbContext,
|
|
MultiCollection multiCollection)
|
|
{
|
|
await dbContext.MultiCollections.AddAsync(multiCollection);
|
|
await dbContext.SaveChangesAsync();
|
|
_searchTargets.SearchTargetsChanged();
|
|
await dbContext.Entry(multiCollection)
|
|
.Collection(c => c.MultiCollectionItems)
|
|
.Query()
|
|
.Include(i => i.Collection)
|
|
.LoadAsync();
|
|
await dbContext.Entry(multiCollection)
|
|
.Collection(c => c.MultiCollectionSmartItems)
|
|
.Query()
|
|
.Include(i => i.SmartCollection)
|
|
.LoadAsync();
|
|
return ProjectToViewModel(multiCollection);
|
|
}
|
|
|
|
private static Task<Validation<BaseError, MultiCollection>> Validate(
|
|
TvContext dbContext,
|
|
CreateMultiCollection request) =>
|
|
ValidateName(dbContext, request)
|
|
.BindT(name => ValidateWeights(request).Map(_ => name))
|
|
.MapT(name => new MultiCollection
|
|
{
|
|
Name = name,
|
|
MultiCollectionItems = request.Items.Bind(i =>
|
|
{
|
|
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;
|
|
})
|
|
.ToList(),
|
|
MultiCollectionSmartItems = request.Items.Bind(i =>
|
|
{
|
|
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;
|
|
})
|
|
.ToList()
|
|
});
|
|
|
|
// Bounds are shared with the update path so the two cannot drift: an out-of-range weight silently
|
|
// removes the source from the rotation (0) or overflows the rotation arithmetic (huge). 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,
|
|
CreateMultiCollection createMultiCollection)
|
|
{
|
|
Validation<BaseError, string> result1 = createMultiCollection.NotEmpty(c => c.Name)
|
|
.Bind(_ => createMultiCollection.NotLongerThan(50)(c => c.Name));
|
|
|
|
bool duplicateName = await dbContext.MultiCollections
|
|
.AnyAsync(c => c.Name == createMultiCollection.Name);
|
|
|
|
Validation<BaseError, Unit> result2 = duplicateName
|
|
? Fail<BaseError, Unit>("MultiCollection name must be unique")
|
|
: Success<BaseError, Unit>(Unit.Default);
|
|
|
|
return (result1, result2).Apply((_, _) => createMultiCollection.Name);
|
|
}
|
|
}
|