using System.Diagnostics.CodeAnalysis; using FluentValidation.Results; using Recyclarr.Cli.Pipelines.QualityProfile.Api; using Recyclarr.Common.Extensions; using Recyclarr.Common.FluentValidation; using Recyclarr.TrashLib.Config.Services; namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; public enum QualityProfileUpdateReason { New, Changed } public record InvalidProfileData(UpdatedQualityProfile Profile, IReadOnlyCollection Errors); public record QualityProfileTransactionData { [SuppressMessage("Usage", "CA2227:Collection properties should be read only")] public ICollection UpdatedProfiles { get; set; } = new List(); public ICollection NonExistentProfiles { get; init; } = new List(); public ICollection InvalidProfiles { get; init; } = new List(); } public class QualityProfileTransactionPhase { [SuppressMessage("ReSharper", "MemberCanBeMadeStatic.Global")] [SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "This non-static method establishes a pattern that will eventually become an interface")] public QualityProfileTransactionData Execute( IReadOnlyCollection guideData, QualityProfileServiceData serviceData) { var transactions = new QualityProfileTransactionData(); BuildUpdatedProfiles(transactions, guideData, serviceData); UpdateProfileScores(transactions.UpdatedProfiles); ValidateProfiles(transactions); return transactions; } private static void ValidateProfiles(QualityProfileTransactionData transactions) { var validator = new UpdatedQualityProfileValidator(); transactions.UpdatedProfiles = transactions.UpdatedProfiles .IsValid(validator, (errors, profile) => transactions.InvalidProfiles.Add(new InvalidProfileData(profile, errors))) .ToList(); } private static void BuildUpdatedProfiles( QualityProfileTransactionData transactions, IEnumerable guideData, QualityProfileServiceData serviceData) { // Match quality profiles in the user's config to profiles in the service. // For each match, we return a tuple including the list of custom format scores ("formatItems"). // Using GroupJoin() because we want a LEFT OUTER JOIN so we can list which quality profiles in config // do not match profiles in Radarr. var matchedProfiles = guideData .GroupJoin(serviceData.Profiles, x => x.Profile.Name, x => x.Name, (x, y) => (x, y.FirstOrDefault()), StringComparer.InvariantCultureIgnoreCase); foreach (var (config, dto) in matchedProfiles) { if (dto is null && !config.ShouldCreate) { transactions.NonExistentProfiles.Add(config.Profile.Name); continue; } var organizer = new QualityItemOrganizer(); var newDto = dto ?? serviceData.Schema; transactions.UpdatedProfiles.Add(new UpdatedQualityProfile { ProfileConfig = config, ProfileDto = newDto, UpdateReason = dto is null ? QualityProfileUpdateReason.New : QualityProfileUpdateReason.Changed, UpdatedQualities = organizer.OrganizeItems(newDto, config.Profile) }); } } private static void UpdateProfileScores(IEnumerable updatedProfiles) { foreach (var profile in updatedProfiles) { profile.InvalidExceptCfNames = GetInvalidExceptCfNames( profile.ProfileConfig.Profile.ResetUnmatchedScores, profile.ProfileDto); profile.UpdatedScores = ProcessScoreUpdates(profile.ProfileConfig, profile.ProfileDto); } } private static IReadOnlyCollection GetInvalidExceptCfNames( ResetUnmatchedScoresConfig resetConfig, QualityProfileDto profileDto) { var except = resetConfig.Except; if (!except.Any()) { return Array.Empty(); } var serviceCfNames = profileDto.FormatItems.Select(x => x.Name).ToList(); return except .Distinct(StringComparer.InvariantCultureIgnoreCase) .Where(x => serviceCfNames.TrueForAll(y => !y.EqualsIgnoreCase(x))) .ToList(); } private static List ProcessScoreUpdates( ProcessedQualityProfileData profileData, QualityProfileDto profileDto) { var scoreMap = profileData.CfScores .FullOuterJoin(profileDto.FormatItems, JoinType.Hash, x => x.FormatId, x => x.Format, // Exists in config, but not in service (these are unusual and should be errors) // See `FormatScoreUpdateReason` for reason why we need this (it's preview mode) l => UpdatedFormatScore.New(l), // Exists in service, but not in config r => UpdatedFormatScore.Reset(r, profileData), // Exists in both service and config (l, r) => UpdatedFormatScore.Updated(r, l)) .ToList(); return scoreMap; } }