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.ExpectedVersions)); 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) { // In-place reconcile (rather than delete-and-reinsert): a schedule item owns 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 keeps its id, so the cascade // never fires and progression survives. // // Two reconcile modes (#259): // * id-based (preferred) — when the client round-trips each existing item's server id, map request // items to existing rows BY id, so per-child state follows the LOGICAL item across reorders and // mid-list inserts rather than staying with whatever previously occupied a slot. // * positional (legacy fallback) — a fully id-less payload keeps the original by-array-position // reconcile. This preserves today's misattribution on legacy reorders; it is temporary and // retires with the §7a Phase-2 If-Match flip. // Index (ordering) is always derived from array position; identity (id) is a separate axis. var orderedItems = request.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); List existingItems = programSchedule.Items.ToList(); List requestIds = orderedItems.Where(i => i.Id.HasValue).Select(i => i.Id.Value).ToList(); if (requestIds.Count == 0) { // ---- positional fallback (verbatim pre-#259 behavior) ---- List orderedExisting = existingItems.OrderBy(i => i.Index).ToList(); int commonCount = Math.Min(orderedExisting.Count, orderedItems.Count); for (var i = 0; i < commonCount; i++) { ProgramScheduleItem existing = orderedExisting[i]; ProgramScheduleItem built = BuildItem(programSchedule, i, orderedItems[i]); if (existing.GetType() == built.GetType()) { built.Id = existing.Id; dbContext.Entry(existing).CurrentValues.SetValues(built); RebuildChildren(existing, orderedItems[i]); } else { dbContext.Remove(existing); programSchedule.Items.Add(built); } } for (int i = commonCount; i < orderedExisting.Count; i++) { dbContext.Remove(orderedExisting[i]); } for (int i = commonCount; i < orderedItems.Count; i++) { programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i])); } } else { // ---- id-based reconcile ---- // Guards run HERE (inside PersistItems, after CheckVersion in Handle) so a client that is BOTH // version-stale and id-stale gets 412 (reload signal) — not 422, which reads as a payload bug // (§7c). Both guards persist nothing (plain BaseError → 422). // 7b: a duplicated id would map two request items onto one row (SetValues twice, last-writer-wins, // one item's config silently lost). Always a client bug. int? duplicateId = requestIds.GroupBy(id => id).Where(g => g.Count() > 1).Select(g => (int?)g.Key) .FirstOrDefault(); if (duplicateId.HasValue) { return new BaseError($"Schedule item id {duplicateId.Value} appears more than once in the request."); } // 7a: an id not belonging to THIS schedule is a stale/foreign identity. Under Phase-1 (a missing // If-Match force-writes) it is a live lost-update signal; inserting-as-new would silently duplicate // the item and return a different id than the client sent. Reject loudly rather than mask it. var existingIds = existingItems.Select(e => e.Id).ToHashSet(); foreach (int id in requestIds) { if (!existingIds.Contains(id)) { return new BaseError($"Schedule item id {id} does not belong to this schedule."); } } // delete pass: existing rows the request no longer references (their fill-group state cascades — // correct, the logical item is gone) var referencedIds = requestIds.ToHashSet(); foreach (ProgramScheduleItem existing in existingItems) { if (!referencedIds.Contains(existing.Id)) { dbContext.Remove(existing); } } // match/insert pass in array order (Index = i) var existingById = existingItems.ToDictionary(e => e.Id); for (var i = 0; i < orderedItems.Count; i++) { ReplaceProgramScheduleItem requestItem = orderedItems[i]; ProgramScheduleItem built = BuildItem(programSchedule, i, requestItem); if (requestItem.Id.HasValue && existingById.TryGetValue(requestItem.Id.Value, out ProgramScheduleItem existing)) { if (existing.GetType() == built.GetType()) { // same TPT subtype: copy all scalars in place (BuildItem is the single source of item // construction, so no field is dropped) and rebuild join rows, keeping the id — and // with it the fill-group index that would otherwise cascade away. State follows the // logical item regardless of its new position. built.Id = existing.Id; dbContext.Entry(existing).CurrentValues.SetValues(built); RebuildChildren(existing, requestItem); } else { // EF can't retype a TPT row in place; replace it. Fill-group state resets and the // response returns a NEW id (the item fundamentally changed — clients re-sync from it). dbContext.Remove(existing); programSchedule.Items.Add(built); } } else { // id-less request item → new (unknown ids were already rejected above) programSchedule.Items.Add(built); } } } // 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); }