- Synchronize custom formats to Radarr - Quality profiles can be assigned scores from the guide - Deletion support for custom formats removed from config or the guide. - Caching system for keeping track of Custom Format IDs and Trash IDs to better support renames, deletions, and other stateful behavior.recyclarr
parent
59934be5d4
commit
c21fc51b23
@ -0,0 +1,22 @@
|
||||
using FluentAssertions.Equivalency;
|
||||
using FluentAssertions.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace TestLibrary.FluentAssertions
|
||||
{
|
||||
public class JsonEquivalencyStep : IEquivalencyStep
|
||||
{
|
||||
public bool CanHandle(IEquivalencyValidationContext context, IEquivalencyAssertionOptions config)
|
||||
{
|
||||
return context.Subject?.GetType().IsAssignableTo(typeof(JToken)) ?? false;
|
||||
}
|
||||
|
||||
public bool Handle(IEquivalencyValidationContext context, IEquivalencyValidator parent,
|
||||
IEquivalencyAssertionOptions config)
|
||||
{
|
||||
((JToken) context.Subject).Should()
|
||||
.BeEquivalentTo((JToken) context.Expectation, context.Because, context.BecauseArgs);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using FluentAssertions.Execution;
|
||||
using NSubstitute.Core.Arguments;
|
||||
|
||||
namespace TestLibrary.NSubstitute
|
||||
{
|
||||
public static class Verify
|
||||
{
|
||||
public static T That<T>(Action<T> action)
|
||||
{
|
||||
return ArgumentMatcher.Enqueue(new AssertionMatcher<T>(action));
|
||||
}
|
||||
|
||||
private class AssertionMatcher<T> : IArgumentMatcher<T>
|
||||
{
|
||||
private readonly Action<T> _assertion;
|
||||
|
||||
public AssertionMatcher(Action<T> assertion)
|
||||
{
|
||||
_assertion = assertion;
|
||||
}
|
||||
|
||||
public bool IsSatisfiedBy(T argument)
|
||||
{
|
||||
using var scope = new AssertionScope();
|
||||
_assertion(argument);
|
||||
|
||||
var failures = scope.Discard().ToList();
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
failures.ForEach(x => Trace.WriteLine(x));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,122 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Serilog;
|
||||
using Trash.Cache;
|
||||
using Trash.Radarr.CustomFormat;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.PersistenceSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class CachePersisterTest
|
||||
{
|
||||
private class Context
|
||||
{
|
||||
public Context()
|
||||
{
|
||||
Log = Substitute.For<ILogger>();
|
||||
ServiceCache = Substitute.For<IServiceCache>();
|
||||
Persister = new CachePersister(Log, ServiceCache);
|
||||
}
|
||||
|
||||
public CachePersister Persister { get; }
|
||||
public ILogger Log { get; }
|
||||
public IServiceCache ServiceCache { get; }
|
||||
}
|
||||
|
||||
private ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
|
||||
{
|
||||
return new(cfName, trashId, new JObject())
|
||||
{
|
||||
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cf_cache_is_valid_after_successful_load()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testCfObj = new CustomFormatCache();
|
||||
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
|
||||
|
||||
ctx.Persister.Load();
|
||||
ctx.Persister.CfCache.Should().BeSameAs(testCfObj);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cf_cache_returns_null_if_not_loaded()
|
||||
{
|
||||
var ctx = new Context();
|
||||
ctx.Persister.Load();
|
||||
ctx.Persister.CfCache.Should().BeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Save_works_with_valid_cf_cache()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testCfObj = new CustomFormatCache();
|
||||
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
|
||||
|
||||
ctx.Persister.Load();
|
||||
ctx.Persister.Save();
|
||||
|
||||
ctx.ServiceCache.Received().Save(Arg.Is(testCfObj));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Saving_without_loading_does_nothing()
|
||||
{
|
||||
var ctx = new Context();
|
||||
ctx.Persister.Save();
|
||||
ctx.ServiceCache.DidNotReceive().Save(Arg.Any<object>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Updating_overwrites_previous_cf_cache_and_updates_cf_data()
|
||||
{
|
||||
var ctx = new Context();
|
||||
|
||||
// Load initial CfCache just to test that it gets replaced
|
||||
var testCfObj = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping> {new("", "") {CustomFormatId = 5}}
|
||||
};
|
||||
ctx.ServiceCache.Load<CustomFormatCache>().Returns(testCfObj);
|
||||
ctx.Persister.Load();
|
||||
|
||||
// Update with new cached items
|
||||
var results = new CustomFormatTransactionData();
|
||||
results.NewCustomFormats.Add(QuickMakeCf("cfname", "trashid", 10));
|
||||
|
||||
var customFormatData = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("", "trashid", new JObject()) {CacheEntry = new TrashIdMapping("trashid", "cfname", 10)}
|
||||
};
|
||||
|
||||
ctx.Persister.Update(customFormatData);
|
||||
ctx.Persister.CfCache.Should().BeEquivalentTo(new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping> {customFormatData[0].CacheEntry!}
|
||||
});
|
||||
|
||||
customFormatData.Should().ContainSingle()
|
||||
.Which.CacheEntry.Should().BeEquivalentTo(
|
||||
new TrashIdMapping("trashid", "cfname") {CustomFormatId = 10});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Updating_sets_cf_cache_without_loading()
|
||||
{
|
||||
var ctx = new Context();
|
||||
ctx.Persister.Update(new List<ProcessedCustomFormatData>());
|
||||
ctx.Persister.CfCache.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,211 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Trash.Radarr;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.GuideSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class ConfigStepTest
|
||||
{
|
||||
[Test]
|
||||
public void All_custom_formats_found_in_guide()
|
||||
{
|
||||
var testProcessedCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
|
||||
{
|
||||
Score = 100
|
||||
},
|
||||
new("name3", "id3", JObject.FromObject(new {name = "name3"}))
|
||||
};
|
||||
|
||||
var testConfig = new CustomFormatConfig[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Names = new List<string> {"name1", "name3"},
|
||||
QualityProfiles = new List<QualityProfileConfig>
|
||||
{
|
||||
new() {Name = "profile1", Score = 50}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new ConfigStep();
|
||||
processor.Process(testProcessedCfs, testConfig);
|
||||
|
||||
processor.RenamedCustomFormats.Should().BeEmpty();
|
||||
processor.CustomFormatsNotInGuide.Should().BeEmpty();
|
||||
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = testProcessedCfs,
|
||||
QualityProfiles = testConfig[0].QualityProfiles
|
||||
}
|
||||
}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cache_names_are_used_instead_of_name_in_json_data()
|
||||
{
|
||||
var testProcessedCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
|
||||
{
|
||||
Score = 100
|
||||
},
|
||||
new("name3", "id3", JObject.FromObject(new {name = "name3"}))
|
||||
{
|
||||
CacheEntry = new TrashIdMapping("id3", "name1")
|
||||
}
|
||||
};
|
||||
|
||||
var testConfig = new CustomFormatConfig[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Names = new List<string> {"name1"}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new ConfigStep();
|
||||
processor.Process(testProcessedCfs, testConfig);
|
||||
|
||||
processor.CustomFormatsNotInGuide.Should().BeEmpty();
|
||||
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{testProcessedCfs[1]}
|
||||
}
|
||||
}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_formats_missing_from_config_are_skipped()
|
||||
{
|
||||
var testProcessedCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "", new JObject()),
|
||||
new("name2", "", new JObject())
|
||||
};
|
||||
|
||||
var testConfig = new CustomFormatConfig[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Names = new List<string> {"name1"}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new ConfigStep();
|
||||
processor.Process(testProcessedCfs, testConfig);
|
||||
|
||||
processor.RenamedCustomFormats.Should().BeEmpty();
|
||||
processor.CustomFormatsNotInGuide.Should().BeEmpty();
|
||||
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "", new JObject())
|
||||
}
|
||||
}
|
||||
}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list()
|
||||
{
|
||||
var testProcessedCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "", new JObject()),
|
||||
new("name2", "", new JObject())
|
||||
};
|
||||
|
||||
var testConfig = new CustomFormatConfig[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Names = new List<string> {"name1", "name3"}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new ConfigStep();
|
||||
processor.Process(testProcessedCfs, testConfig);
|
||||
|
||||
processor.RenamedCustomFormats.Should().BeEmpty();
|
||||
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "", new JObject())
|
||||
}
|
||||
}
|
||||
}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_formats_with_same_trash_id_and_same_name_in_cache_are_in_renamed_list()
|
||||
{
|
||||
var testProcessedCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", new JObject())
|
||||
{
|
||||
CacheEntry = new TrashIdMapping("id1", "name2")
|
||||
},
|
||||
new("name2", "id2", new JObject())
|
||||
{
|
||||
CacheEntry = new TrashIdMapping("id2", "name1")
|
||||
}
|
||||
};
|
||||
|
||||
var testConfig = new CustomFormatConfig[]
|
||||
{
|
||||
new()
|
||||
{
|
||||
Names = new List<string> {"name1", "name2"}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new ConfigStep();
|
||||
processor.Process(testProcessedCfs, testConfig);
|
||||
|
||||
processor.RenamedCustomFormats.Should().BeEquivalentTo(testProcessedCfs, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
processor.CustomFormatsNotInGuide.Should().BeEmpty();
|
||||
processor.ConfigData.Should().BeEquivalentTo(new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = testProcessedCfs
|
||||
}
|
||||
}, op => op
|
||||
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
|
||||
.WhenTypeIs<JToken>());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.FluentAssertions;
|
||||
using Trash.Radarr;
|
||||
using Trash.Radarr.CustomFormat.Guide;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.GuideSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class CustomFormatStepTest
|
||||
{
|
||||
private class Context
|
||||
{
|
||||
public List<CustomFormatData> TestGuideData { get; } = new()
|
||||
{
|
||||
new CustomFormatData
|
||||
{
|
||||
Score = 100,
|
||||
Json = JsonConvert.SerializeObject(new
|
||||
{
|
||||
trash_id = "id1",
|
||||
name = "name1"
|
||||
}, Formatting.Indented)
|
||||
},
|
||||
new CustomFormatData
|
||||
{
|
||||
Score = 200,
|
||||
Json = JsonConvert.SerializeObject(new
|
||||
{
|
||||
trash_id = "id2",
|
||||
name = "name2"
|
||||
}, Formatting.Indented)
|
||||
},
|
||||
new CustomFormatData
|
||||
{
|
||||
Json = JsonConvert.SerializeObject(new
|
||||
{
|
||||
trash_id = "id3",
|
||||
name = "name3"
|
||||
}, Formatting.Indented)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
[TestCase("name1", 0)]
|
||||
[TestCase("naME1", 0)]
|
||||
[TestCase("DifferentName", 1)]
|
||||
public void Match_cf_in_guide_with_different_name_with_cache_using_same_name_in_config(string variableCfName,
|
||||
int outdatedCount)
|
||||
{
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1"}}
|
||||
};
|
||||
|
||||
var testCache = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping>
|
||||
{
|
||||
new("id1", "name1")
|
||||
}
|
||||
};
|
||||
|
||||
var testGuideData = new List<CustomFormatData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Score = 100,
|
||||
Json = JsonConvert.SerializeObject(new
|
||||
{
|
||||
trash_id = "id1",
|
||||
name = variableCfName
|
||||
}, Formatting.Indented)
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(testGuideData, testConfig, testCache);
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().HaveCount(outdatedCount);
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new(variableCfName, "id1", JObject.FromObject(new {name = variableCfName}))
|
||||
{
|
||||
Score = 100,
|
||||
CacheEntry = testCache.TrashIdMappings[0]
|
||||
}
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cache_entry_is_not_set_when_id_is_different()
|
||||
{
|
||||
var guideData = new List<CustomFormatData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Json = @"{'name': 'name1', 'trash_id': 'id1'}"
|
||||
}
|
||||
};
|
||||
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1"}}
|
||||
};
|
||||
|
||||
var testCache = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping>
|
||||
{
|
||||
new("id1000", "name1")
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(guideData, testConfig, testCache);
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Count.Should().Be(1);
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
|
||||
{
|
||||
Score = null,
|
||||
CacheEntry = null
|
||||
}
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Cfs_not_in_config_are_skipped()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1", "name3"}}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"}))
|
||||
{
|
||||
Score = 100
|
||||
},
|
||||
new("name3", "id3", JObject.FromObject(new {name = "name3"}))
|
||||
{
|
||||
Score = null
|
||||
}
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Config_cfs_in_different_sections_are_processed()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1", "name3"}},
|
||||
new() {Names = new List<string> {"name2"}}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100},
|
||||
new("name2", "id2", JObject.FromObject(new {name = "name2"})) {Score = 200},
|
||||
new("name3", "id3", JObject.FromObject(new {name = "name3"})) {Score = null}
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_format_is_deleted_if_in_config_and_cache_but_not_in_guide()
|
||||
{
|
||||
var guideData = new List<CustomFormatData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Json = @"{'name': 'name1', 'trash_id': 'id1'}"
|
||||
}
|
||||
};
|
||||
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1"}}
|
||||
};
|
||||
|
||||
var testCache = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping> {new("id1000", "name1")}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(guideData, testConfig, testCache);
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should()
|
||||
.BeEquivalentTo(new TrashIdMapping("id1000", "name1"));
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.Parse(@"{'name': 'name1'}"))
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_format_is_deleted_if_not_in_config_but_in_cache_and_in_guide()
|
||||
{
|
||||
var cache = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping> {new("id1", "3D", 9)}
|
||||
};
|
||||
|
||||
var guideCfs = new List<CustomFormatData>
|
||||
{
|
||||
new() {Json = "{'name': '3D', 'trash_id': 'id1'}"}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(guideCfs, Array.Empty<CustomFormatConfig>(), cache);
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEquivalentTo(cache.TrashIdMappings[0]);
|
||||
processor.ProcessedCustomFormats.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_format_name_in_cache_is_updated_if_renamed_in_guide_and_config()
|
||||
{
|
||||
var guideData = new List<CustomFormatData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Json = @"{'name': 'name2', 'trash_id': 'id1'}"
|
||||
}
|
||||
};
|
||||
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name2"}}
|
||||
};
|
||||
|
||||
var testCache = new CustomFormatCache
|
||||
{
|
||||
TrashIdMappings = new List<TrashIdMapping> {new("id1", "name1")}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(guideData, testConfig, testCache);
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should()
|
||||
.ContainSingle().Which.CacheEntry.Should()
|
||||
.BeEquivalentTo(new TrashIdMapping("id1", "name2"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Match_cf_names_regardless_of_case_in_config()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"name1", "NAME1"}}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100}
|
||||
},
|
||||
op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Non_existent_cfs_in_config_are_skipped()
|
||||
{
|
||||
var ctx = new Context();
|
||||
var testConfig = new List<CustomFormatConfig>
|
||||
{
|
||||
new() {Names = new List<string> {"doesnt_exist"}}
|
||||
};
|
||||
|
||||
var processor = new CustomFormatStep();
|
||||
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
|
||||
|
||||
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
|
||||
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
|
||||
processor.ProcessedCustomFormats.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,137 @@
|
||||
using System.Collections.Generic;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using Trash.Radarr;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Processors.GuideSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class QualityProfileStepTest
|
||||
{
|
||||
[Test]
|
||||
public void No_score_used_if_no_score_in_config_or_guide()
|
||||
{
|
||||
var testConfigData = new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", new JObject()) {Score = null}
|
||||
},
|
||||
QualityProfiles = new List<QualityProfileConfig>
|
||||
{
|
||||
new() {Name = "profile1"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileStep();
|
||||
processor.Process(testConfigData);
|
||||
|
||||
processor.ProfileScores.Should().BeEmpty();
|
||||
processor.CustomFormatsWithoutScore.Should().Equal(new List<object> {("name1", "id1", "profile1")});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Overwrite_score_from_guide_if_config_defines_score()
|
||||
{
|
||||
var testConfigData = new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("", "id1", new JObject()) {Score = 100}
|
||||
},
|
||||
QualityProfiles = new List<QualityProfileConfig>
|
||||
{
|
||||
new() {Name = "profile1", Score = 50}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileStep();
|
||||
processor.Process(testConfigData);
|
||||
|
||||
processor.ProfileScores.Should().ContainKey("profile1")
|
||||
.WhichValue.Should().BeEquivalentTo(new List<QualityProfileCustomFormatScoreEntry>
|
||||
{
|
||||
new(testConfigData[0].CustomFormats[0], 50)
|
||||
});
|
||||
|
||||
processor.CustomFormatsWithoutScore.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Use_guide_score_if_no_score_in_config()
|
||||
{
|
||||
var testConfigData = new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("", "id1", new JObject()) {Score = 100}
|
||||
},
|
||||
QualityProfiles = new List<QualityProfileConfig>
|
||||
{
|
||||
new() {Name = "profile1"},
|
||||
new() {Name = "profile2", Score = null}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileStep();
|
||||
processor.Process(testConfigData);
|
||||
|
||||
var expectedScoreEntries = new List<QualityProfileCustomFormatScoreEntry>
|
||||
{
|
||||
new(testConfigData[0].CustomFormats[0], 100)
|
||||
};
|
||||
|
||||
processor.ProfileScores.Should().BeEquivalentTo(
|
||||
new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
|
||||
{
|
||||
{"profile1", expectedScoreEntries},
|
||||
{"profile2", expectedScoreEntries}
|
||||
});
|
||||
|
||||
processor.CustomFormatsWithoutScore.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Zero_score_is_not_ignored()
|
||||
{
|
||||
var testConfigData = new List<ProcessedConfigData>
|
||||
{
|
||||
new()
|
||||
{
|
||||
CustomFormats = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("name1", "id1", new JObject()) {Score = 0}
|
||||
},
|
||||
QualityProfiles = new List<QualityProfileConfig>
|
||||
{
|
||||
new() {Name = "profile1"}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileStep();
|
||||
processor.Process(testConfigData);
|
||||
|
||||
processor.ProfileScores.Should().ContainKey("profile1")
|
||||
.WhichValue.Should().BeEquivalentTo(new List<QualityProfileCustomFormatScoreEntry>
|
||||
{
|
||||
new(testConfigData[0].CustomFormats[0], 0)
|
||||
});
|
||||
|
||||
processor.CustomFormatsWithoutScore.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Trash.Radarr;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class PersistenceProcessorTest
|
||||
{
|
||||
[Test]
|
||||
public void Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config()
|
||||
{
|
||||
var steps = Substitute.For<IPersistenceProcessorSteps>();
|
||||
var cfApi = Substitute.For<ICustomFormatService>();
|
||||
var qpApi = Substitute.For<IQualityProfileService>();
|
||||
var config = new RadarrConfiguration {DeleteOldCustomFormats = true};
|
||||
|
||||
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
|
||||
var deletedCfsInCache = new Collection<TrashIdMapping>();
|
||||
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
|
||||
|
||||
var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps);
|
||||
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
|
||||
|
||||
steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any<List<JObject>>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
|
||||
{
|
||||
var steps = Substitute.For<IPersistenceProcessorSteps>();
|
||||
var cfApi = Substitute.For<ICustomFormatService>();
|
||||
var qpApi = Substitute.For<IQualityProfileService>();
|
||||
var config = new RadarrConfiguration(); // DeleteOldCustomFormats should default to false
|
||||
|
||||
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
|
||||
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
|
||||
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
|
||||
|
||||
var processor = new PersistenceProcessor(cfApi, qpApi, config, () => steps);
|
||||
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
|
||||
|
||||
steps.JsonTransactionStep.DidNotReceive()
|
||||
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,47 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.PersistenceSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class CustomFormatApiPersistenceStepTest
|
||||
{
|
||||
private ProcessedCustomFormatData QuickMakeCf(string cfName, string trashId, int cfId)
|
||||
{
|
||||
return new(cfName, trashId, new JObject())
|
||||
{
|
||||
CacheEntry = new TrashIdMapping(trashId, cfName) {CustomFormatId = cfId}
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task All_api_operations_behave_normally()
|
||||
{
|
||||
var transactions = new CustomFormatTransactionData();
|
||||
transactions.NewCustomFormats.Add(QuickMakeCf("cfname1", "trashid1", 1));
|
||||
transactions.UpdatedCustomFormats.Add(QuickMakeCf("cfname2", "trashid2", 2));
|
||||
transactions.UnchangedCustomFormats.Add(QuickMakeCf("cfname3", "trashid3", 3));
|
||||
transactions.DeletedCustomFormatIds.Add(new TrashIdMapping("trashid4", "cfname4") {CustomFormatId = 4});
|
||||
|
||||
var api = Substitute.For<ICustomFormatService>();
|
||||
|
||||
var processor = new CustomFormatApiPersistenceStep();
|
||||
await processor.Process(api, transactions);
|
||||
|
||||
Received.InOrder(() =>
|
||||
{
|
||||
api.CreateCustomFormat(transactions.NewCustomFormats.First());
|
||||
api.UpdateCustomFormat(transactions.UpdatedCustomFormats.First());
|
||||
api.DeleteCustomFormat(4);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,366 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.FluentAssertions;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.PersistenceSteps;
|
||||
|
||||
/* Sample Custom Format response from Radarr API
|
||||
{
|
||||
"id": 1,
|
||||
"name": "test",
|
||||
"includeCustomFormatWhenRenaming": false,
|
||||
"specifications": [
|
||||
{
|
||||
"name": "asdf",
|
||||
"implementation": "ReleaseTitleSpecification",
|
||||
"implementationName": "Release Title",
|
||||
"infoLink": "https://wiki.servarr.com/Radarr_Settings#Custom_Formats_2",
|
||||
"negate": false,
|
||||
"required": false,
|
||||
"fields": [
|
||||
{
|
||||
"order": 0,
|
||||
"name": "value",
|
||||
"label": "Regular Expression",
|
||||
"value": "asdf",
|
||||
"type": "textbox",
|
||||
"advanced": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
*/
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class JsonTransactionStepTest
|
||||
{
|
||||
[TestCase(1, "cf2")]
|
||||
[TestCase(2, "cf1")]
|
||||
[TestCase(null, "cf1")]
|
||||
public void Updates_using_combination_of_id_and_name(int? id, string guideCfName)
|
||||
{
|
||||
const string radarrCfData = @"{
|
||||
'id': 1,
|
||||
'name': 'cf1',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}";
|
||||
var guideCfData = JObject.Parse(@"{
|
||||
'name': 'cf1',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'new': 'valuenew',
|
||||
'fields': {
|
||||
'value': 'value2'
|
||||
}
|
||||
}]
|
||||
}");
|
||||
var cacheEntry = id != null ? new TrashIdMapping("", "") {CustomFormatId = id.Value} : null;
|
||||
|
||||
var guideCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new(guideCfName, "", guideCfData) {CacheEntry = cacheEntry}
|
||||
};
|
||||
|
||||
var processor = new JsonTransactionStep();
|
||||
processor.Process(guideCfs, new[] {JObject.Parse(radarrCfData)});
|
||||
|
||||
var expectedTransactions = new CustomFormatTransactionData();
|
||||
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
|
||||
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
|
||||
|
||||
const string expectedJsonData = @"{
|
||||
'id': 1,
|
||||
'name': 'cf1',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'new': 'valuenew',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value2'
|
||||
}]
|
||||
}]
|
||||
}";
|
||||
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
|
||||
.BeEquivalentTo(JObject.Parse(expectedJsonData), op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Combination_of_create_update_and_no_change_and_verify_proper_json_merging()
|
||||
{
|
||||
const string radarrCfData = @"[{
|
||||
'id': 1,
|
||||
'name': 'user_defined',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}, {
|
||||
'id': 2,
|
||||
'name': 'updated',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'untouchable': 'field',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}, {
|
||||
'id': 3,
|
||||
'name': 'no_change',
|
||||
'specifications': [{
|
||||
'name': 'spec4',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}]";
|
||||
var guideCfData = JsonConvert.DeserializeObject<List<JObject>>(@"[{
|
||||
'name': 'created',
|
||||
'specifications': [{
|
||||
'name': 'spec5',
|
||||
'fields': {
|
||||
'value': 'value2'
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
'name': 'updated_different_name',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'negate': true,
|
||||
'new_spec_field': 'new_spec_value',
|
||||
'fields': {
|
||||
'value': 'value2',
|
||||
'new_field': 'new_value'
|
||||
}
|
||||
}, {
|
||||
'name': 'new_spec',
|
||||
'fields': {
|
||||
'value': 'value3'
|
||||
}
|
||||
}]
|
||||
}, {
|
||||
'name': 'no_change',
|
||||
'specifications': [{
|
||||
'name': 'spec4',
|
||||
'negate': false,
|
||||
'fields': {
|
||||
'value': 'value1'
|
||||
}
|
||||
}]
|
||||
}]");
|
||||
|
||||
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
|
||||
var guideCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("created", "", guideCfData[0]),
|
||||
new("updated_different_name", "", guideCfData[1])
|
||||
{
|
||||
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 2}
|
||||
},
|
||||
new("no_change", "", guideCfData[2])
|
||||
};
|
||||
|
||||
var processor = new JsonTransactionStep();
|
||||
processor.Process(guideCfs, radarrCfs);
|
||||
|
||||
var expectedJson = new[]
|
||||
{
|
||||
@"{
|
||||
'name': 'created',
|
||||
'specifications': [{
|
||||
'name': 'spec5',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value2'
|
||||
}]
|
||||
}]
|
||||
}",
|
||||
@"{
|
||||
'id': 2,
|
||||
'name': 'updated_different_name',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'negate': true,
|
||||
'new_spec_field': 'new_spec_value',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'untouchable': 'field',
|
||||
'value': 'value2',
|
||||
'new_field': 'new_value'
|
||||
}]
|
||||
}, {
|
||||
'name': 'new_spec',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value3'
|
||||
}]
|
||||
}]
|
||||
}",
|
||||
@"{
|
||||
'id': 3,
|
||||
'name': 'no_change',
|
||||
'specifications': [{
|
||||
'name': 'spec4',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}"
|
||||
};
|
||||
|
||||
var expectedTransactions = new CustomFormatTransactionData();
|
||||
expectedTransactions.NewCustomFormats.Add(guideCfs[0]);
|
||||
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[1]);
|
||||
expectedTransactions.UnchangedCustomFormats.Add(guideCfs[2]);
|
||||
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
|
||||
|
||||
processor.Transactions.NewCustomFormats.First().Json.Should()
|
||||
.BeEquivalentTo(JObject.Parse(expectedJson[0]), op => op.Using(new JsonEquivalencyStep()));
|
||||
|
||||
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
|
||||
.BeEquivalentTo(JObject.Parse(expectedJson[1]), op => op.Using(new JsonEquivalencyStep()));
|
||||
|
||||
processor.Transactions.UnchangedCustomFormats.First().Json.Should()
|
||||
.BeEquivalentTo(JObject.Parse(expectedJson[2]), op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Deletes_happen_before_updates()
|
||||
{
|
||||
const string radarrCfData = @"[{
|
||||
'id': 1,
|
||||
'name': 'updated',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}, {
|
||||
'id': 2,
|
||||
'name': 'deleted',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'untouchable': 'field',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}]";
|
||||
var guideCfData = JObject.Parse(@"{
|
||||
'name': 'updated',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'fields': {
|
||||
'value': 'value2'
|
||||
}
|
||||
}]
|
||||
}");
|
||||
var deletedCfsInCache = new List<TrashIdMapping>
|
||||
{
|
||||
new("", "") {CustomFormatId = 2}
|
||||
};
|
||||
|
||||
var guideCfs = new List<ProcessedCustomFormatData>
|
||||
{
|
||||
new("updated", "", guideCfData) {CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}}
|
||||
};
|
||||
|
||||
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
|
||||
|
||||
var processor = new JsonTransactionStep();
|
||||
processor.Process(guideCfs, radarrCfs);
|
||||
processor.RecordDeletions(deletedCfsInCache, radarrCfs);
|
||||
|
||||
var expectedJson = @"{
|
||||
'id': 1,
|
||||
'name': 'updated',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value2'
|
||||
}]
|
||||
}]
|
||||
}";
|
||||
var expectedTransactions = new CustomFormatTransactionData();
|
||||
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("", "", 2));
|
||||
expectedTransactions.UpdatedCustomFormats.Add(guideCfs[0]);
|
||||
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
|
||||
|
||||
processor.Transactions.UpdatedCustomFormats.First().Json.Should()
|
||||
.BeEquivalentTo(JObject.Parse(expectedJson), op => op.Using(new JsonEquivalencyStep()));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Only_delete_correct_cfs()
|
||||
{
|
||||
const string radarrCfData = @"[{
|
||||
'id': 1,
|
||||
'name': 'not_deleted',
|
||||
'specifications': [{
|
||||
'name': 'spec1',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}, {
|
||||
'id': 2,
|
||||
'name': 'deleted',
|
||||
'specifications': [{
|
||||
'name': 'spec2',
|
||||
'negate': false,
|
||||
'fields': [{
|
||||
'name': 'value',
|
||||
'untouchable': 'field',
|
||||
'value': 'value1'
|
||||
}]
|
||||
}]
|
||||
}]";
|
||||
var deletedCfsInCache = new List<TrashIdMapping>
|
||||
{
|
||||
new("testtrashid", "testname") {CustomFormatId = 2},
|
||||
new("", "not_deleted") {CustomFormatId = 3}
|
||||
};
|
||||
|
||||
var radarrCfs = JsonConvert.DeserializeObject<List<JObject>>(radarrCfData);
|
||||
|
||||
var processor = new JsonTransactionStep();
|
||||
processor.RecordDeletions(deletedCfsInCache, radarrCfs);
|
||||
|
||||
var expectedTransactions = new CustomFormatTransactionData();
|
||||
expectedTransactions.DeletedCustomFormatIds.Add(new TrashIdMapping("testtrashid", "testname", 2));
|
||||
processor.Transactions.Should().BeEquivalentTo(expectedTransactions);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using FluentAssertions;
|
||||
using FluentAssertions.Json;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using TestLibrary.NSubstitute;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.PersistenceSteps;
|
||||
|
||||
namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
[TestFixture]
|
||||
[Parallelizable(ParallelScope.All)]
|
||||
public class QualityProfileApiPersistenceStepTest
|
||||
{
|
||||
[Test]
|
||||
public void Invalid_quality_profile_names_are_reported()
|
||||
{
|
||||
const string radarrQualityProfileData = @"[{'name': 'profile1'}]";
|
||||
|
||||
var api = Substitute.For<IQualityProfileService>();
|
||||
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
|
||||
|
||||
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
|
||||
{
|
||||
{"wrong_profile_name", new List<QualityProfileCustomFormatScoreEntry>()}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileApiPersistenceStep();
|
||||
processor.Process(api, cfScores);
|
||||
|
||||
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
|
||||
processor.InvalidProfileNames.Should().BeEquivalentTo("wrong_profile_name");
|
||||
processor.UpdatedScores.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Scores_are_set_in_quality_profile()
|
||||
{
|
||||
const string radarrQualityProfileData = @"[{
|
||||
'name': 'profile1',
|
||||
'upgradeAllowed': false,
|
||||
'cutoff': 20,
|
||||
'items': [{
|
||||
'quality': {
|
||||
'id': 10,
|
||||
'name': 'Raw-HD',
|
||||
'source': 'tv',
|
||||
'resolution': 1080,
|
||||
'modifier': 'rawhd'
|
||||
},
|
||||
'items': [],
|
||||
'allowed': false
|
||||
}
|
||||
],
|
||||
'minFormatScore': 0,
|
||||
'cutoffFormatScore': 0,
|
||||
'formatItems': [{
|
||||
'format': 4,
|
||||
'name': '3D',
|
||||
'score': 0
|
||||
},
|
||||
{
|
||||
'format': 3,
|
||||
'name': 'BR-DISK',
|
||||
'score': 0
|
||||
},
|
||||
{
|
||||
'format': 1,
|
||||
'name': 'asdf2',
|
||||
'score': 0
|
||||
}
|
||||
],
|
||||
'language': {
|
||||
'id': 1,
|
||||
'name': 'English'
|
||||
},
|
||||
'id': 1
|
||||
}]";
|
||||
|
||||
var api = Substitute.For<IQualityProfileService>();
|
||||
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
|
||||
|
||||
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
|
||||
{
|
||||
{
|
||||
"profile1", new List<QualityProfileCustomFormatScoreEntry>
|
||||
{
|
||||
new(new ProcessedCustomFormatData("", "", new JObject())
|
||||
{
|
||||
// First match by ID
|
||||
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4}
|
||||
}, 100),
|
||||
new(new ProcessedCustomFormatData("", "", new JObject())
|
||||
{
|
||||
// Should NOT match because we do not use names to assign scores
|
||||
CacheEntry = new TrashIdMapping("", "BR-DISK")
|
||||
}, 101),
|
||||
new(new ProcessedCustomFormatData("", "", new JObject())
|
||||
{
|
||||
// Second match by ID
|
||||
CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1}
|
||||
}, 102)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var processor = new QualityProfileApiPersistenceStep();
|
||||
processor.Process(api, cfScores);
|
||||
|
||||
var expectedProfileJson = JObject.Parse(@"{
|
||||
'name': 'profile1',
|
||||
'upgradeAllowed': false,
|
||||
'cutoff': 20,
|
||||
'items': [{
|
||||
'quality': {
|
||||
'id': 10,
|
||||
'name': 'Raw-HD',
|
||||
'source': 'tv',
|
||||
'resolution': 1080,
|
||||
'modifier': 'rawhd'
|
||||
},
|
||||
'items': [],
|
||||
'allowed': false
|
||||
}
|
||||
],
|
||||
'minFormatScore': 0,
|
||||
'cutoffFormatScore': 0,
|
||||
'formatItems': [{
|
||||
'format': 4,
|
||||
'name': '3D',
|
||||
'score': 100
|
||||
},
|
||||
{
|
||||
'format': 3,
|
||||
'name': 'BR-DISK',
|
||||
'score': 0
|
||||
},
|
||||
{
|
||||
'format': 1,
|
||||
'name': 'asdf2',
|
||||
'score': 102
|
||||
}
|
||||
],
|
||||
'language': {
|
||||
'id': 1,
|
||||
'name': 'English'
|
||||
},
|
||||
'id': 1
|
||||
}");
|
||||
|
||||
api.Received()
|
||||
.UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
|
||||
processor.InvalidProfileNames.Should().BeEmpty();
|
||||
processor.UpdatedScores.Should().ContainKey("profile1").WhichValue.Should().BeEquivalentTo(
|
||||
cfScores.Values.First()[0],
|
||||
cfScores.Values.First()[2]);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Trash.Extensions
|
||||
{
|
||||
internal static class LinqExtensions
|
||||
{
|
||||
internal static IEnumerable<TResult> FullOuterGroupJoin<TA, TB, TKey, TResult>(
|
||||
this IEnumerable<TA> a,
|
||||
IEnumerable<TB> b,
|
||||
Func<TA, TKey> selectKeyA,
|
||||
Func<TB, TKey> selectKeyB,
|
||||
Func<IEnumerable<TA>, IEnumerable<TB>, TKey, TResult> projection,
|
||||
IEqualityComparer<TKey>? cmp = null)
|
||||
{
|
||||
cmp ??= EqualityComparer<TKey>.Default;
|
||||
var alookup = a.ToLookup(selectKeyA, cmp);
|
||||
var blookup = b.ToLookup(selectKeyB, cmp);
|
||||
|
||||
var keys = new HashSet<TKey>(alookup.Select(p => p.Key), cmp);
|
||||
keys.UnionWith(blookup.Select(p => p.Key));
|
||||
|
||||
var join = from key in keys
|
||||
let xa = alookup[key]
|
||||
let xb = blookup[key]
|
||||
select projection(xa, xb, key);
|
||||
|
||||
return join;
|
||||
}
|
||||
|
||||
internal static IEnumerable<TResult> FullOuterJoin<TA, TB, TKey, TResult>(
|
||||
this IEnumerable<TA> a,
|
||||
IEnumerable<TB> b,
|
||||
Func<TA, TKey> selectKeyA,
|
||||
Func<TB, TKey> selectKeyB,
|
||||
Func<TA, TB, TKey, TResult> projection,
|
||||
TA? defaultA = default,
|
||||
TB? defaultB = default,
|
||||
IEqualityComparer<TKey>? cmp = null)
|
||||
{
|
||||
cmp ??= EqualityComparer<TKey>.Default;
|
||||
var alookup = a.ToLookup(selectKeyA, cmp);
|
||||
var blookup = b.ToLookup(selectKeyB, cmp);
|
||||
|
||||
var keys = new HashSet<TKey>(alookup.Select(p => p.Key), cmp);
|
||||
keys.UnionWith(blookup.Select(p => p.Key));
|
||||
|
||||
var join = from key in keys
|
||||
from xa in alookup[key].DefaultIfEmpty(defaultA)
|
||||
from xb in blookup[key].DefaultIfEmpty(defaultB)
|
||||
select projection(xa, xb, key);
|
||||
|
||||
return join;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Flurl;
|
||||
using Flurl.Http;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Config;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Api
|
||||
{
|
||||
internal class CustomFormatService : ICustomFormatService
|
||||
{
|
||||
private readonly IServiceConfiguration _serviceConfig;
|
||||
|
||||
public CustomFormatService(IServiceConfiguration serviceConfig)
|
||||
{
|
||||
_serviceConfig = serviceConfig;
|
||||
}
|
||||
|
||||
public async Task<List<JObject>> GetCustomFormats()
|
||||
{
|
||||
return await BaseUrl()
|
||||
.AppendPathSegment("customformat")
|
||||
.GetJsonAsync<List<JObject>>();
|
||||
}
|
||||
|
||||
public async Task CreateCustomFormat(ProcessedCustomFormatData cf)
|
||||
{
|
||||
var response = await BaseUrl()
|
||||
.AppendPathSegment("customformat")
|
||||
.PostJsonAsync(cf.Json)
|
||||
.ReceiveJson<JObject>();
|
||||
|
||||
cf.SetCache((int) response["id"]);
|
||||
}
|
||||
|
||||
public async Task UpdateCustomFormat(ProcessedCustomFormatData cf)
|
||||
{
|
||||
// Set the cache first, since it's needed to perform the update. This case will apply to CFs we update that
|
||||
// exist in Radarr but not the cache (e.g. moving to a new machine, same-named CF was created manually)
|
||||
if (cf.CacheEntry == null)
|
||||
{
|
||||
cf.SetCache((int) cf.Json["id"]);
|
||||
}
|
||||
|
||||
await BaseUrl()
|
||||
.AppendPathSegment($"customformat/{cf.GetCustomFormatId()}")
|
||||
.PutJsonAsync(cf.Json)
|
||||
.ReceiveJson<JObject>();
|
||||
}
|
||||
|
||||
public async Task DeleteCustomFormat(int customFormatId)
|
||||
{
|
||||
await BaseUrl()
|
||||
.AppendPathSegment($"customformat/{customFormatId}")
|
||||
.DeleteAsync();
|
||||
}
|
||||
|
||||
private string BaseUrl()
|
||||
{
|
||||
return _serviceConfig.BuildUrl();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,36 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Flurl;
|
||||
using Flurl.Http;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Config;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Api
|
||||
{
|
||||
internal class QualityProfileService : IQualityProfileService
|
||||
{
|
||||
private readonly IServiceConfiguration _serviceConfig;
|
||||
|
||||
public QualityProfileService(IServiceConfiguration serviceConfig)
|
||||
{
|
||||
_serviceConfig = serviceConfig;
|
||||
}
|
||||
|
||||
private string BaseUrl => _serviceConfig.BuildUrl();
|
||||
|
||||
public async Task<List<JObject>> GetQualityProfiles()
|
||||
{
|
||||
return await BaseUrl
|
||||
.AppendPathSegment("qualityprofile")
|
||||
.GetJsonAsync<List<JObject>>();
|
||||
}
|
||||
|
||||
public async Task<JObject> UpdateQualityProfile(JObject profileJson, int id)
|
||||
{
|
||||
return await BaseUrl
|
||||
.AppendPathSegment($"qualityprofile/{id}")
|
||||
.PutJsonAsync(profileJson)
|
||||
.ReceiveJson<JObject>();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
namespace Trash.Radarr.CustomFormat
|
||||
{
|
||||
public enum ApiOperationType
|
||||
{
|
||||
Create,
|
||||
Update,
|
||||
NoChange,
|
||||
Delete
|
||||
}
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Serilog;
|
||||
using Trash.Cache;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat
|
||||
{
|
||||
public class CachePersister : ICachePersister
|
||||
{
|
||||
private readonly IServiceCache _cache;
|
||||
|
||||
public CachePersister(ILogger log, IServiceCache cache)
|
||||
{
|
||||
Log = log;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
private ILogger Log { get; }
|
||||
public CustomFormatCache? CfCache { get; private set; }
|
||||
|
||||
public void Load()
|
||||
{
|
||||
CfCache = _cache.Load<CustomFormatCache>();
|
||||
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
|
||||
if (CfCache != null)
|
||||
{
|
||||
Log.Debug("Loaded Cache");
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("Custom format cache does not exist; proceeding without it");
|
||||
}
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
if (CfCache == null)
|
||||
{
|
||||
Log.Debug("Not saving cache because it is null");
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Debug("Saving Cache");
|
||||
_cache.Save(CfCache);
|
||||
}
|
||||
|
||||
public void Update(IEnumerable<ProcessedCustomFormatData> customFormats)
|
||||
{
|
||||
Log.Debug("Updating cache");
|
||||
CfCache = new CustomFormatCache();
|
||||
CfCache!.TrashIdMappings.AddRange(customFormats
|
||||
.Where(cf => cf.CacheEntry != null)
|
||||
.Select(cf => cf.CacheEntry!));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,14 @@
|
||||
using System.Collections.Generic;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat
|
||||
{
|
||||
public interface ICachePersister
|
||||
{
|
||||
CustomFormatCache? CfCache { get; }
|
||||
void Load();
|
||||
void Save();
|
||||
void Update(IEnumerable<ProcessedCustomFormatData> customFormats);
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Models
|
||||
{
|
||||
public class ProcessedConfigData
|
||||
{
|
||||
public List<ProcessedCustomFormatData> CustomFormats { get; init; } = new();
|
||||
public List<QualityProfileConfig> QualityProfiles { get; init; } = new();
|
||||
}
|
||||
}
|
@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Trash.Extensions;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public class ConfigStep : IConfigStep
|
||||
{
|
||||
public List<ProcessedCustomFormatData> RenamedCustomFormats { get; private set; } = new();
|
||||
public List<string> CustomFormatsNotInGuide { get; } = new();
|
||||
public List<ProcessedConfigData> ConfigData { get; } = new();
|
||||
|
||||
public void Process(IReadOnlyCollection<ProcessedCustomFormatData> processedCfs,
|
||||
IEnumerable<CustomFormatConfig> config)
|
||||
{
|
||||
foreach (var configCf in config)
|
||||
{
|
||||
// Also get the list of CFs that are in the guide
|
||||
var cfsInGuide = configCf.Names
|
||||
.ToLookup(n =>
|
||||
{
|
||||
// Iterate up to two times:
|
||||
// 1. Find a match in the cache using name in config. If not found,
|
||||
// 2. Find a match in the guide using name in config.
|
||||
return processedCfs.FirstOrDefault(
|
||||
cf => cf.CacheEntry?.CustomFormatName.EqualsIgnoreCase(n) ?? false) ??
|
||||
processedCfs.FirstOrDefault(
|
||||
cf => cf.Name.EqualsIgnoreCase(n));
|
||||
});
|
||||
|
||||
// Names grouped under 'null' were not found in the guide OR the cache
|
||||
CustomFormatsNotInGuide.AddRange(
|
||||
cfsInGuide[null].Distinct(StringComparer.CurrentCultureIgnoreCase));
|
||||
|
||||
ConfigData.Add(new ProcessedConfigData
|
||||
{
|
||||
CustomFormats = cfsInGuide.Where(grp => grp.Key != null).Select(grp => grp.Key!).ToList(),
|
||||
QualityProfiles = configCf.QualityProfiles
|
||||
});
|
||||
}
|
||||
|
||||
var allCfs = ConfigData
|
||||
.SelectMany(cd => cd.CustomFormats.Select(cf => cf))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
// List of CFs in cache vs guide that have mismatched Trash ID. This means that a CF was renamed
|
||||
// to the same name as a previous CF's name, and we should treat that one as missing.
|
||||
// CustomFormatsSameNameDiffTrashId = allCfs
|
||||
// .Where(cf => cf.CacheEntry != null)
|
||||
// .GroupBy(cf => allCfs.FirstOrDefault(
|
||||
// cf2 => cf2.Name.EqualsIgnoreCase(cf.CacheEntry!.CustomFormatName) &&
|
||||
// !cf2.TrashId.EqualsIgnoreCase(cf.CacheEntry.TrashId)))
|
||||
// .Where(grp => grp.Key != null)
|
||||
// .Select(grp => grp.Append(grp.Key!).ToList())
|
||||
// .ToList();
|
||||
|
||||
// CFs in the guide that match the same TrashID in cache but have different names. Warn the user that it
|
||||
// is renamed in the guide and they need to update their config.
|
||||
RenamedCustomFormats = allCfs
|
||||
.Where(cf => cf.CacheEntry != null && !cf.CacheEntry.CustomFormatName.EqualsIgnoreCase(cf.Name))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Extensions;
|
||||
using Trash.Radarr.CustomFormat.Guide;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public class CustomFormatStep : ICustomFormatStep
|
||||
{
|
||||
public List<(string, string)> CustomFormatsWithOutdatedNames { get; } = new();
|
||||
public List<ProcessedCustomFormatData> ProcessedCustomFormats { get; } = new();
|
||||
public List<TrashIdMapping> DeletedCustomFormatsInCache { get; } = new();
|
||||
|
||||
public void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> config,
|
||||
CustomFormatCache? cache)
|
||||
{
|
||||
var allConfigCfNames = config
|
||||
.SelectMany(c => c.Names)
|
||||
.Distinct(StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var processedCfs = customFormatGuideData
|
||||
.Select(cf => ProcessCustomFormatData(cf, cache))
|
||||
.ToList();
|
||||
|
||||
// Perform updates and deletions based on matches in the cache. Matches in the cache are by ID.
|
||||
foreach (var cf in processedCfs) //.Where(cf => cf.CacheEntry != null))
|
||||
{
|
||||
// Does the name of the CF in the guide match a name in the config? If yes, we keep it.
|
||||
var configName = allConfigCfNames.FirstOrDefault(n => n.EqualsIgnoreCase(cf.Name));
|
||||
if (configName != null)
|
||||
{
|
||||
if (cf.CacheEntry != null)
|
||||
{
|
||||
// The cache entry might be using an old name. This will happen if:
|
||||
// - A user has synced this CF before, AND
|
||||
// - The name of the CF in the guide changed, AND
|
||||
// - The user updated the name in their config to match the name in the guide.
|
||||
cf.CacheEntry.CustomFormatName = cf.Name;
|
||||
}
|
||||
|
||||
ProcessedCustomFormats.Add(cf);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Does the name of the CF in the cache match a name in the config? If yes, we keep it.
|
||||
configName = allConfigCfNames.FirstOrDefault(n => n.EqualsIgnoreCase(cf.CacheEntry?.CustomFormatName));
|
||||
if (configName != null)
|
||||
{
|
||||
// Config name is out of sync with the guide and should be updated
|
||||
CustomFormatsWithOutdatedNames.Add((configName, cf.Name));
|
||||
ProcessedCustomFormats.Add(cf);
|
||||
}
|
||||
|
||||
// If we get here, we can't find a match in the config using cache or guide name, so the user must have
|
||||
// removed it from their config. This will get marked for deletion when we process those later in
|
||||
// ProcessDeletedCustomFormats().
|
||||
}
|
||||
|
||||
// Orphaned entries in cache represent custom formats we need to delete.
|
||||
ProcessDeletedCustomFormats(cache);
|
||||
}
|
||||
|
||||
private static ProcessedCustomFormatData ProcessCustomFormatData(CustomFormatData guideData,
|
||||
CustomFormatCache? cache)
|
||||
{
|
||||
JObject obj = JObject.Parse(guideData.Json);
|
||||
var name = obj["name"].Value<string>();
|
||||
var trashId = obj["trash_id"].Value<string>();
|
||||
|
||||
// Remove trash_id, it's metadata that is not meant for Radarr itself
|
||||
// Radarr supposedly drops this anyway, but I prefer it to be removed by TrashUpdater
|
||||
obj.Property("trash_id").Remove();
|
||||
|
||||
return new ProcessedCustomFormatData(name, trashId, obj)
|
||||
{
|
||||
Score = guideData.Score,
|
||||
CacheEntry = cache?.TrashIdMappings.FirstOrDefault(c => c.TrashId == trashId)
|
||||
};
|
||||
}
|
||||
|
||||
private void ProcessDeletedCustomFormats(CustomFormatCache? cache)
|
||||
{
|
||||
if (cache == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
static bool MatchCfInCache(ProcessedCustomFormatData cf, TrashIdMapping c)
|
||||
=> cf.CacheEntry != null && cf.CacheEntry.TrashId == c.TrashId;
|
||||
|
||||
// Delete if CF is in cache and not in the guide or config
|
||||
DeletedCustomFormatsInCache.AddRange(cache.TrashIdMappings
|
||||
.Where(c => !ProcessedCustomFormats.Any(cf => MatchCfInCache(cf, c))));
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
using System.Collections.Generic;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public interface IConfigStep
|
||||
{
|
||||
List<ProcessedCustomFormatData> RenamedCustomFormats { get; }
|
||||
List<string> CustomFormatsNotInGuide { get; }
|
||||
List<ProcessedConfigData> ConfigData { get; }
|
||||
|
||||
void Process(IReadOnlyCollection<ProcessedCustomFormatData> processedCfs,
|
||||
IEnumerable<CustomFormatConfig> config);
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using Trash.Radarr.CustomFormat.Guide;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public interface ICustomFormatStep
|
||||
{
|
||||
List<ProcessedCustomFormatData> ProcessedCustomFormats { get; }
|
||||
List<TrashIdMapping> DeletedCustomFormatsInCache { get; }
|
||||
List<(string, string)> CustomFormatsWithOutdatedNames { get; }
|
||||
|
||||
void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> config,
|
||||
CustomFormatCache? cache);
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public interface IQualityProfileStep
|
||||
{
|
||||
Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
|
||||
List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
|
||||
void Process(IEnumerable<ProcessedConfigData> configData);
|
||||
}
|
||||
}
|
@ -0,0 +1,43 @@
|
||||
using System.Collections.Generic;
|
||||
using Trash.Extensions;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
|
||||
{
|
||||
public class QualityProfileStep : IQualityProfileStep
|
||||
{
|
||||
public Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; } = new();
|
||||
public List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } = new();
|
||||
|
||||
public void Process(IEnumerable<ProcessedConfigData> configData)
|
||||
{
|
||||
foreach (var config in configData)
|
||||
foreach (var profile in config.QualityProfiles)
|
||||
foreach (var cf in config.CustomFormats)
|
||||
{
|
||||
// Check if there is a score we can use. Priority is:
|
||||
// 1. Score from the YAML config is used. If user did not provide,
|
||||
// 2. Score from the guide is used. If the guide did not have one,
|
||||
// 3. Warn the user and skip it.
|
||||
var scoreToUse = profile.Score;
|
||||
if (scoreToUse == null)
|
||||
{
|
||||
if (cf.Score == null)
|
||||
{
|
||||
CustomFormatsWithoutScore.Add((cf.Name, cf.TrashId, profile.Name));
|
||||
}
|
||||
else
|
||||
{
|
||||
scoreToUse = cf.Score.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (scoreToUse != null)
|
||||
{
|
||||
ProfileScores.GetOrCreate(profile.Name)
|
||||
.Add(new QualityProfileCustomFormatScoreEntry(cf, scoreToUse.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors
|
||||
{
|
||||
internal interface IGuideProcessor
|
||||
{
|
||||
IReadOnlyCollection<ProcessedCustomFormatData> ProcessedCustomFormats { get; }
|
||||
IReadOnlyCollection<string> CustomFormatsNotInGuide { get; }
|
||||
IReadOnlyCollection<ProcessedConfigData> ConfigData { get; }
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
|
||||
IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
|
||||
IReadOnlyCollection<TrashIdMapping> DeletedCustomFormatsInCache { get; }
|
||||
List<(string, string)> CustomFormatsWithOutdatedNames { get; }
|
||||
|
||||
Task BuildGuideData(IReadOnlyList<CustomFormatConfig> config, CustomFormatCache? cache);
|
||||
void Reset();
|
||||
}
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
using Trash.Radarr.CustomFormat.Processors.PersistenceSteps;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors
|
||||
{
|
||||
public interface IPersistenceProcessor
|
||||
{
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
|
||||
IReadOnlyCollection<string> InvalidProfileNames { get; }
|
||||
CustomFormatTransactionData Transactions { get; }
|
||||
|
||||
Task PersistCustomFormats(IReadOnlyCollection<ProcessedCustomFormatData> guideCfs,
|
||||
IEnumerable<TrashIdMapping> deletedCfsInCache,
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> profileScores);
|
||||
|
||||
void Reset();
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
using System.Threading.Tasks;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public class CustomFormatApiPersistenceStep : ICustomFormatApiPersistenceStep
|
||||
{
|
||||
public async Task Process(ICustomFormatService api, CustomFormatTransactionData transactions)
|
||||
{
|
||||
foreach (var cf in transactions.NewCustomFormats)
|
||||
{
|
||||
await api.CreateCustomFormat(cf);
|
||||
}
|
||||
|
||||
foreach (var cf in transactions.UpdatedCustomFormats)
|
||||
{
|
||||
await api.UpdateCustomFormat(cf);
|
||||
}
|
||||
|
||||
foreach (var cfId in transactions.DeletedCustomFormatIds)
|
||||
{
|
||||
await api.DeleteCustomFormat(cfId.CustomFormatId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,10 @@
|
||||
using System.Threading.Tasks;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public interface ICustomFormatApiPersistenceStep
|
||||
{
|
||||
Task Process(ICustomFormatService api, CustomFormatTransactionData transactions);
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public interface IJsonTransactionStep
|
||||
{
|
||||
CustomFormatTransactionData Transactions { get; }
|
||||
|
||||
void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
|
||||
IReadOnlyCollection<JObject> radarrCfs);
|
||||
|
||||
void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, List<JObject> radarrCfs);
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public interface IQualityProfileApiPersistenceStep
|
||||
{
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
|
||||
IReadOnlyCollection<string> InvalidProfileNames { get; }
|
||||
|
||||
Task Process(IQualityProfileService api,
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> cfScores);
|
||||
}
|
||||
}
|
@ -0,0 +1,164 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Extensions;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
using Trash.Radarr.CustomFormat.Models.Cache;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public class CustomFormatTransactionData
|
||||
{
|
||||
public List<ProcessedCustomFormatData> NewCustomFormats { get; } = new();
|
||||
public List<ProcessedCustomFormatData> UpdatedCustomFormats { get; } = new();
|
||||
public List<TrashIdMapping> DeletedCustomFormatIds { get; } = new();
|
||||
public List<ProcessedCustomFormatData> UnchangedCustomFormats { get; } = new();
|
||||
}
|
||||
|
||||
public class JsonTransactionStep : IJsonTransactionStep
|
||||
{
|
||||
public CustomFormatTransactionData Transactions { get; } = new();
|
||||
|
||||
public void Process(IEnumerable<ProcessedCustomFormatData> guideCfs,
|
||||
IReadOnlyCollection<JObject> radarrCfs)
|
||||
{
|
||||
foreach (var (guideCf, radarrCf) in guideCfs
|
||||
.Select(gcf => (GuideCf: gcf, RadarrCf: FindRadarrCf(radarrCfs, gcf))))
|
||||
{
|
||||
var guideCfJson = BuildNewRadarrCf(guideCf.Json);
|
||||
|
||||
// no match; we add this CF as brand new
|
||||
if (radarrCf == null)
|
||||
{
|
||||
guideCf.Json = guideCfJson;
|
||||
Transactions.NewCustomFormats.Add(guideCf);
|
||||
}
|
||||
// found match in radarr CFs; update the existing CF
|
||||
else
|
||||
{
|
||||
guideCf.Json = (JObject) radarrCf.DeepClone();
|
||||
UpdateRadarrCf(guideCf.Json, guideCfJson);
|
||||
|
||||
if (!JToken.DeepEquals(radarrCf, guideCf.Json))
|
||||
{
|
||||
Transactions.UpdatedCustomFormats.Add(guideCf);
|
||||
}
|
||||
else
|
||||
{
|
||||
Transactions.UnchangedCustomFormats.Add(guideCf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordDeletions(IEnumerable<TrashIdMapping> deletedCfsInCache, List<JObject> radarrCfs)
|
||||
{
|
||||
// The 'Where' excludes cached CFs that were deleted manually by the user in Radarr
|
||||
// FindRadarrCf() specifies 'null' for name because we should never delete unless an ID is found
|
||||
foreach (var del in deletedCfsInCache.Where(
|
||||
del => FindRadarrCf(radarrCfs, del.CustomFormatId, null) != null))
|
||||
{
|
||||
Transactions.DeletedCustomFormatIds.Add(del);
|
||||
}
|
||||
}
|
||||
|
||||
private static JObject? FindRadarrCf(IReadOnlyCollection<JObject> radarrCfs, ProcessedCustomFormatData guideCf)
|
||||
{
|
||||
return FindRadarrCf(radarrCfs, guideCf.CacheEntry?.CustomFormatId, guideCf.Name);
|
||||
}
|
||||
|
||||
private static JObject? FindRadarrCf(IReadOnlyCollection<JObject> radarrCfs, int? cfId, string? cfName)
|
||||
{
|
||||
JObject? match = null;
|
||||
|
||||
// Try to find match in cache first
|
||||
if (cfId != null)
|
||||
{
|
||||
match = radarrCfs.FirstOrDefault(rcf => cfId == rcf["id"].Value<int>());
|
||||
}
|
||||
|
||||
// If we don't find by ID, search by name (if a name was given)
|
||||
if (match == null && cfName != null)
|
||||
{
|
||||
match = radarrCfs.FirstOrDefault(rcf => cfName.EqualsIgnoreCase(rcf["name"].Value<string>()));
|
||||
}
|
||||
|
||||
return match;
|
||||
}
|
||||
|
||||
private static void UpdateRadarrCf(JObject cfToModify, JObject cfToMergeFrom)
|
||||
{
|
||||
MergeProperties(cfToModify, cfToMergeFrom, JTokenType.Array);
|
||||
|
||||
var radarrSpecs = cfToModify["specifications"].Children<JObject>();
|
||||
var guideSpecs = cfToMergeFrom["specifications"].Children<JObject>();
|
||||
|
||||
var matchedGuideSpecs = guideSpecs
|
||||
.GroupBy(gs => radarrSpecs.FirstOrDefault(gss => KeyMatch(gss, gs, "name")))
|
||||
.SelectMany(kvp => kvp.Select(gs => new {GuideSpec = gs, RadarrSpec = kvp.Key}));
|
||||
|
||||
var newRadarrSpecs = new JArray();
|
||||
|
||||
foreach (var match in matchedGuideSpecs)
|
||||
{
|
||||
if (match.RadarrSpec != null)
|
||||
{
|
||||
MergeProperties(match.RadarrSpec, match.GuideSpec);
|
||||
newRadarrSpecs.Add(match.RadarrSpec);
|
||||
}
|
||||
else
|
||||
{
|
||||
newRadarrSpecs.Add(match.GuideSpec);
|
||||
}
|
||||
}
|
||||
|
||||
cfToModify["specifications"] = newRadarrSpecs;
|
||||
}
|
||||
|
||||
private static bool KeyMatch(JObject left, JObject right, string keyName)
|
||||
=> left[keyName].Value<string>() == right[keyName].Value<string>();
|
||||
|
||||
private static void MergeProperties(JObject radarrCf, JObject guideCfJson,
|
||||
JTokenType exceptType = JTokenType.None)
|
||||
{
|
||||
foreach (var guideProp in guideCfJson.Properties().Where(p => p.Value.Type != exceptType))
|
||||
{
|
||||
if (guideProp.Value.Type == JTokenType.Array &&
|
||||
radarrCf.TryGetValue(guideProp.Name, out var radarrArray))
|
||||
{
|
||||
((JArray) radarrArray).Merge(guideProp.Value, new JsonMergeSettings
|
||||
{
|
||||
MergeArrayHandling = MergeArrayHandling.Merge
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
radarrCf[guideProp.Name] = guideProp.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JObject BuildNewRadarrCf(JObject jsonPayload)
|
||||
{
|
||||
// Information on required fields from nitsua
|
||||
/*
|
||||
ok, for the specs.. you need name, implementation, negate, required, fields
|
||||
for fields you need name & value
|
||||
top level you need name, includeCustomFormatWhenRenaming, specs and id (if updating)
|
||||
everything else radarr can handle with backend logic
|
||||
*/
|
||||
|
||||
foreach (var child in jsonPayload["specifications"])
|
||||
{
|
||||
// convert from `"fields": {}` to `"fields": [{}]` (object to array of object)
|
||||
// Weirdly the exported version of a custom format is not in array form, but the API requires the array
|
||||
// even if there's only one element.
|
||||
var field = child["fields"];
|
||||
field["name"] = "value";
|
||||
child["fields"] = new JArray {field};
|
||||
}
|
||||
|
||||
return jsonPayload;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Trash.Extensions;
|
||||
using Trash.Radarr.CustomFormat.Api;
|
||||
using Trash.Radarr.CustomFormat.Models;
|
||||
|
||||
namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
|
||||
{
|
||||
public class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceStep
|
||||
{
|
||||
private readonly List<string> _invalidProfileNames = new();
|
||||
private readonly Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> _updatedScores = new();
|
||||
|
||||
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores => _updatedScores;
|
||||
public IReadOnlyCollection<string> InvalidProfileNames => _invalidProfileNames;
|
||||
|
||||
public async Task Process(IQualityProfileService api,
|
||||
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> cfScores)
|
||||
{
|
||||
var radarrProfiles = (await api.GetQualityProfiles())
|
||||
.Select(p => (Name: p["name"].ToString(), Json: p))
|
||||
.ToList();
|
||||
|
||||
var profileScores = cfScores
|
||||
.GroupJoin(radarrProfiles,
|
||||
s => s.Key,
|
||||
p => p.Name,
|
||||
(s, pList) => (s.Key, s.Value,
|
||||
pList.SelectMany(p => p.Json["formatItems"].Children<JObject>()).ToList()),
|
||||
StringComparer.InvariantCultureIgnoreCase);
|
||||
|
||||
foreach (var (profileName, scoreList, jsonList) in profileScores)
|
||||
{
|
||||
if (jsonList.Count == 0)
|
||||
{
|
||||
_invalidProfileNames.Add(profileName);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var (score, json) in scoreList
|
||||
.Select(s => (s, FindJsonScoreEntry(s, jsonList)))
|
||||
.Where(p => p.Item2 != null))
|
||||
{
|
||||
var currentScore = (int) json!["score"];
|
||||
if (currentScore == score.Score)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
json!["score"] = score.Score;
|
||||
_updatedScores.GetOrCreate(profileName).Add(score);
|
||||
}
|
||||
|
||||
var jsonRoot = (JObject) jsonList.First().Root;
|
||||
await api.UpdateQualityProfile(jsonRoot, (int) jsonRoot["id"]);
|
||||
}
|
||||
}
|
||||
|
||||
private static JObject? FindJsonScoreEntry(QualityProfileCustomFormatScoreEntry score,
|
||||
IEnumerable<JObject> jsonList)
|
||||
{
|
||||
return jsonList.FirstOrDefault(j
|
||||
=> score.CustomFormat.CacheEntry != null &&
|
||||
(int) j["format"] == score.CustomFormat.CacheEntry.CustomFormatId);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace Trash.YamlDotNet
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Property)]
|
||||
public sealed class CannotBeEmptyAttribute : RequiredAttribute
|
||||
{
|
||||
public override bool IsValid(object? value)
|
||||
{
|
||||
return base.IsValid(value) &&
|
||||
value is IEnumerable list &&
|
||||
list.GetEnumerator().MoveNext();
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in new issue