Round-3 review returned BLOCKED: must-fix (b) was not closed. It was right, and the root cause it named is the point of this commit — the previous correction "was scoped to the four sites the reviewer listed rather than re-derived by grep". Fixing the list is not fixing the class. That is the same failure as B1, where the gate covered the two writers already in hand and missed CreateChannelFromLineup. Re-grepped the behavior class instead. Three survivors, two of them missed and one freshly introduced by the correction itself: - CreateMultiCollectionHandler.cs — the create twin of a comment whose UPDATE twin I corrected and whose create twin I never opened. Present tense, and contradicted by two tests in this same PR. - decisions.md — corrected one line in that file and left its sibling. - MultiCollectionItemWeight.cs (and its decisions.md mirror) — the ceiling rationale still claimed unbounded weights overflow the sum. They cannot: EffectiveWeight clamps before every sum and CycleLength widens to long. The earlier pass pattern-matched on the word "filtered" and left the identical defect on the ceiling. The ceiling's real job is the floor's argument — a billion is not a share of airtime any more than 0 is — so it now says that, and credits the clamp with the arithmetic safety it actually provides. Also corrected the writer claim to the right predicate: not "two persisting writers" (Add*ToPlaylist and Trakt persist it too, hardcoded) but two writers that persist a CALLER-SUPPLIED order. The full set is now classified persists-caller-value / persists-hardcoded / in-memory, including Engine/PlaylistHelper, which the previous "two Preview handlers" phrasing missed. That bullet has been wrong three times in the same shape; it now says so, since a lesson that keeps being re-learned is worth recording as a pattern rather than a fact. The BOM check caught this commit re-adding a BOM to the one file patched with utf-8-sig — the same trap, an hour after writing it down. Stripped; the mechanical pre-push check is what makes that survivable. Core.Tests 566, ErsatzTV.Tests 1673, 0 failed. Format verify exit 0. decisions.md +90/-0 (append-only guard green). Refs #70 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
122 lines
5.2 KiB
C#
122 lines
5.2 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 -- 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,
|
|
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);
|
|
}
|
|
}
|