using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; namespace ErsatzTV.Application.Plex; public class UpdatePlexPathReplacementsHandler : IRequestHandler> { private readonly IMediaSourceRepository _mediaSourceRepository; public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => _mediaSourceRepository = mediaSourceRepository; public Task> Handle( UpdatePlexPathReplacements request, CancellationToken cancellationToken) => Validate(request, cancellationToken) .MapT(pms => MergePathReplacements(request, pms)) .Bind(v => v.ToEitherAsync()); private Task MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource) { plexMediaSource.PathReplacements ??= []; var incoming = request.PathReplacements.Map(Project).ToList(); var toAdd = incoming.Filter(r => r.Id < 1).ToList(); var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); var toUpdate = incoming.Except(toAdd).ToList(); return _mediaSourceRepository.UpdatePathReplacements(plexMediaSource.Id, toAdd, toUpdate, toRemove); } private static PlexPathReplacement Project(PlexPathReplacementItem vm) => new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath }; private async Task> Validate( UpdatePlexPathReplacements request, CancellationToken cancellationToken) { Option maybeSource = await _mediaSourceRepository.GetPlex(request.PlexMediaSourceId, cancellationToken); foreach (PlexMediaSource plexMediaSource in maybeSource) { return ValidatePathReplacements(request, plexMediaSource); } return Fail( BaseError.New($"Plex media source {request.PlexMediaSourceId} does not exist.")); } // Programmatic clients now reach this handler directly (#202), so validate what the Blazor form // enforced plus the cross-source ownership hole (finding 2c/8): reject a null list, null items, // blank RemotePath/LocalPath, and any positive Id NOT owned by this source — all before any write, // so there is no partial mutation. private static Validation ValidatePathReplacements( UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource) { List items = request.PathReplacements; if (items is null) { return Fail(BaseError.New("[PathReplacements] is required")); } if (items.Any(i => i is null)) { return Fail(BaseError.New("[PathReplacements] must not contain null items")); } if (items.Any(i => string.IsNullOrWhiteSpace(i.PlexPath) || string.IsNullOrWhiteSpace(i.LocalPath))) { return Fail( BaseError.New("Each path replacement requires a non-empty RemotePath and LocalPath")); } var ownedIds = Optional(plexMediaSource.PathReplacements).Flatten().Map(pr => pr.Id).ToHashSet(); List foreignIds = items.Filter(i => i.Id > 0 && !ownedIds.Contains(i.Id)).Map(i => i.Id).ToList(); if (foreignIds.Count > 0) { return Fail( BaseError.New( $"Path replacement {foreignIds[0]} does not belong to Plex media source {request.PlexMediaSourceId}")); } return Success(plexMediaSource); } }