using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; namespace ErsatzTV.Application.Emby; public class UpdateEmbyPathReplacementsHandler : IRequestHandler> { private readonly IMediaSourceRepository _mediaSourceRepository; public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => _mediaSourceRepository = mediaSourceRepository; public async Task> Handle( UpdateEmbyPathReplacements request, CancellationToken cancellationToken) { Option maybeSource = await _mediaSourceRepository.GetEmby(request.EmbyMediaSourceId, cancellationToken); return await maybeSource.Match( Some: async embyMediaSource => { Option maybeError = ValidateItems(request, embyMediaSource); return await maybeError.Match( Some: error => Task.FromResult(Left(error)), None: async () => { await MergePathReplacements(request, embyMediaSource); return Right(Unit.Default); }); }, None: () => Task.FromResult( Left( BaseError.New($"Emby media source {request.EmbyMediaSourceId} does not exist.")))); } private Task MergePathReplacements( UpdateEmbyPathReplacements request, EmbyMediaSource embyMediaSource) { embyMediaSource.PathReplacements ??= new List(); var incoming = request.PathReplacements.Map(Project).ToList(); var toAdd = incoming.Filter(r => r.Id < 1).ToList(); var toRemove = embyMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); var toUpdate = incoming.Except(toAdd).ToList(); return _mediaSourceRepository.UpdatePathReplacements(embyMediaSource.Id, toAdd, toUpdate, toRemove); } private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) => new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; // Defense-in-depth for design #202 findings 2c/8 — the repo UPDATE is scoped by // EmbyMediaSourceId, but reject a foreign/blank/null row here too, before any write, so the // mutation is all-or-nothing. private static Option ValidateItems( UpdateEmbyPathReplacements request, EmbyMediaSource embyMediaSource) { List items = request.PathReplacements ?? []; if (items.Any(item => item is null)) { return BaseError.New("Path replacement items must not be null."); } if (items.Any(item => string.IsNullOrWhiteSpace(item.EmbyPath) || string.IsNullOrWhiteSpace(item.LocalPath))) { return BaseError.New("Each path replacement requires a non-blank Emby path and local path."); } var existingIds = (embyMediaSource.PathReplacements ?? new List()) .Map(pr => pr.Id) .ToList(); var foreignIds = items.Filter(item => item.Id > 0 && !existingIds.Contains(item.Id)) .Map(item => item.Id) .ToList(); if (foreignIds.Count > 0) { return BaseError.New( $"Path replacement id(s) {string.Join(", ", foreignIds)} do not belong to Emby media source " + $"{request.EmbyMediaSourceId}."); } return Option.None; } }