using System.Threading.Channels; using ErsatzTV.Application.Playouts; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Errors; using ErsatzTV.Core.Scheduling; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.ProgramSchedules.Mapper; namespace ErsatzTV.Application.ProgramSchedules; public class ReplaceProgramScheduleItemsHandler( IDbContextFactory dbContextFactory, ChannelWriter channel) : ProgramScheduleItemCommandBase, IRequestHandler>> { public async Task>> Handle( ReplaceProgramScheduleItems request, CancellationToken cancellationToken) { await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); Option maybeProgramSchedule = await ProgramScheduleMustExist(dbContext, request.ProgramScheduleId, cancellationToken); return await maybeProgramSchedule.Match( Some: async programSchedule => { Validation validation = await Validate(dbContext, request, programSchedule); // Introduce the optimistic-concurrency check as a standalone Either AFTER the validation // pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not // flattened to a generic 422 by Join() (issue #253 / api-conventions §7a). Either validated = LanguageExtensions.ToEither(validation) .Bind(ps => ps.CheckVersion(request.ExpectedVersion)); return await validated.Match( Right: ps => PersistItems(dbContext, request, ps, cancellationToken), Left: error => Task.FromResult>>(error)); }, None: () => Task.FromResult>>( new NotFoundError("[ProgramScheduleId] does not exist."))); } private async Task>> PersistItems( TvContext dbContext, ReplaceProgramScheduleItems request, ProgramSchedule programSchedule, CancellationToken cancellationToken) { // Positional in-place reconcile (rather than delete-and-reinsert): a schedule item owns the // persisted fill-group/shuffle enumerator state via PlayoutScheduleItemFillGroupIndex, whose // ProgramScheduleItemId FK is OnDelete(Cascade). Removing and re-inserting every item — as the // original handler did on every save, including a no-op PUT-back — cascade-deleted that state for // all playouts using the schedule (#252). Reusing the existing item row for the same-typed slot // keeps its id, so the cascade never fires and progression survives. The request DTO carries no // stable item id, so position is the only key available here; true content-aware stable identity // is deferred to the shared concurrency/round-trip contract in #253. var orderedItems = request.Items.OrderBy(i => i.Index).ToList(); List existingItems = programSchedule.Items.OrderBy(i => i.Index).ToList(); // load the watermark/graphics join rows for the existing items so they can be rebuilt in place await dbContext.Entry(programSchedule) .Collection(ps => ps.Items) .Query() .Include(i => i.ProgramScheduleItemWatermarks) .Include(i => i.ProgramScheduleItemGraphicsElements) .LoadAsync(cancellationToken); int commonCount = Math.Min(existingItems.Count, orderedItems.Count); for (var i = 0; i < commonCount; i++) { ProgramScheduleItem existing = existingItems[i]; ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]); if (existing.GetType() == built.GetType()) { // same TPT subtype: copy all scalar values in place (BuildItem is the single source of // item construction, so no field is silently dropped) and rebuild the join rows, keeping // the item's id — and with it the fill-group index that would otherwise cascade away. built.Id = existing.Id; dbContext.Entry(existing).CurrentValues.SetValues(built); RebuildChildren(existing, orderedItems[i]); } else { // EF can't change a TPT row's type in place; this slot must be replaced (its fill-group // index resets, which is acceptable — the item fundamentally changed). dbContext.Remove(existing); programSchedule.Items.Add(built); } } // remove surplus existing items for (int i = commonCount; i < existingItems.Count; i++) { dbContext.Remove(existingItems[i]); } // add surplus incoming items for (int i = commonCount; i < orderedItems.Count; i++) { programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i])); } // Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar // change, so without an explicit bump EF would emit no root UPDATE and the concurrency token // would never fire (nor rotate other clients' ETags). Bumping guarantees both on every save, // including a no-op same-items PUT-back (issue #253 / api-conventions §7a). programSchedule.Version++; // Save through the guard so an EF concurrency failure (a racing writer won between our load and // save) maps to 412 rather than surfacing as a 500. On failure, propagate the error WITHOUT // running the post-save reload/enqueue below. Either saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken); if (saved.IsLeft) { return saved.Map(_ => (IEnumerable)[]); } // refresh any playouts that use this schedule // post-commit side effect runs on CancellationToken.None so a late request cancellation // can't abort it after the commit landed (#254) foreach (Playout playout in programSchedule.Playouts) { await channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh), CancellationToken.None); } // reload with the full navigation graph before projecting: BuildItem creates the watermark/graphics // join rows with only their foreign-key ids set, so the tracked entities have null Watermark / // GraphicsElement navs that ProjectToViewModel dereferences (would 500 on PUT — see #229). List persisted = await dbContext.ProgramScheduleItems .Filter(psi => psi.ProgramScheduleId == programSchedule.Id) .IncludeScheduleItemDetails() .OrderBy(i => i.Index) .ToListAsync(cancellationToken); return persisted.Map(ProjectToViewModel).ToList(); } // Rebuild the watermark/graphics join rows for an item updated in place. Clearing severs the required // relationship so EF deletes the orphaned join rows (same net effect the delete-and-reinsert path had), // then re-add from the request. Only the parent item row is preserved — the join rows carry no state. private static void RebuildChildren(ProgramScheduleItem item, ReplaceProgramScheduleItem request) { item.ProgramScheduleItemWatermarks ??= []; item.ProgramScheduleItemWatermarks.Clear(); foreach (int watermarkId in request.WatermarkIds) { item.ProgramScheduleItemWatermarks.Add( new ProgramScheduleItemWatermark { ProgramScheduleItem = item, WatermarkId = watermarkId }); } item.ProgramScheduleItemGraphicsElements ??= []; item.ProgramScheduleItemGraphicsElements.Clear(); foreach (int graphicsElementId in request.GraphicsElementIds) { item.ProgramScheduleItemGraphicsElements.Add( new ProgramScheduleItemGraphicsElement { ProgramScheduleItem = item, GraphicsElementId = graphicsElementId }); } } private static async Task> Validate( TvContext dbContext, ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) { Validation validation = PlayoutModesMustBeValid(request, programSchedule) .Bind(programSchedule => CollectionTypesMustBeValid(request, programSchedule)) .Bind(programSchedule => PlaybackOrdersMustBeValid(request, programSchedule)); return await validation.ToEither().Match( Left: error => Task.FromResult>( Fail(error)), Right: validProgramSchedule => FillerConfigurationsMustBeValid(dbContext, request, validProgramSchedule)); } private static Validation PlayoutModesMustBeValid( ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) => request.Items.Map(item => PlayoutModeMustBeValid(item, programSchedule)).Sequence() .Map(_ => programSchedule); private static Validation CollectionTypesMustBeValid( ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) => request.Items.Map(item => CollectionTypeMustBeValid(item, programSchedule)).Sequence() .Map(_ => programSchedule); private static async Task> FillerConfigurationsMustBeValid( TvContext dbContext, ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) { foreach (ReplaceProgramScheduleItem item in request.Items) { Either result = await FillerConfigurationMustBeValid( dbContext, item, programSchedule); if (result.IsLeft) { return result.ToValidation(); } } return programSchedule; } private static Validation PlaybackOrdersMustBeValid( ReplaceProgramScheduleItems request, ProgramSchedule programSchedule) { var keyOrders = new Dictionary>(); foreach (ReplaceProgramScheduleItem item in request.Items) { if (item.PlaybackOrder is PlaybackOrder.ShuffleInOrder && item.FillWithGroupMode is not FillWithGroupMode.None) { return new BaseError("Shuffle in Order cannot be used with Fill With Group Mode"); } var key = new CollectionKey( item.CollectionType, item.CollectionId, item.MediaItemId, item.MultiCollectionId, item.SmartCollectionId, item.RerunCollectionId, item.PlaylistId, item.SearchQuery); if (keyOrders.TryGetValue(key, out System.Collections.Generic.HashSet playbackOrders)) { playbackOrders.Add(item.PlaybackOrder); keyOrders[key] = playbackOrders; } else { keyOrders.Add(key, new System.Collections.Generic.HashSet { item.PlaybackOrder }); } } return Optional(keyOrders.Values.Count(set => set.Count != 1)) .Filter(count => count == 0) .Map(_ => programSchedule) .ToValidation("A collection must not use multiple playback orders"); } private sealed record CollectionKey( CollectionType CollectionType, int? CollectionId, int? MediaItemId, int? MultiCollectionId, int? SmartCollectionId, int? RerunCollectionId, int? PlaylistId, string SearchQuery); }