diff --git a/CHANGELOG.md b/CHANGELOG.md index 93a86d77..17f23ce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New setting `reset_unmatched_scores` under `custom_formats.quality_profiles` in YAML config which + allows Trash Updater to set scores to 0 if they were not in the list of custom format names or + listed but had no score applied (e.g. no score in guide). + ### Changed - Support the new custom format structure in the guide: JSON files are parsed directly now. Trash diff --git a/src/Trash.TestLibrary/CfTestUtils.cs b/src/Trash.TestLibrary/CfTestUtils.cs new file mode 100644 index 00000000..c50b3328 --- /dev/null +++ b/src/Trash.TestLibrary/CfTestUtils.cs @@ -0,0 +1,14 @@ +using System.Linq; +using Trash.Radarr.CustomFormat.Models; + +namespace Trash.TestLibrary +{ + public static class CfTestUtils + { + public static QualityProfileCustomFormatScoreMapping NewMapping(params FormatMappingEntry[] entries) + => new(false) {Mapping = entries.ToList()}; + + public static QualityProfileCustomFormatScoreMapping NewMappingWithReset(params FormatMappingEntry[] entries) + => new(true) {Mapping = entries.ToList()}; + } +} diff --git a/src/Trash.TestLibrary/Trash.TestLibrary.csproj b/src/Trash.TestLibrary/Trash.TestLibrary.csproj new file mode 100644 index 00000000..18753431 --- /dev/null +++ b/src/Trash.TestLibrary/Trash.TestLibrary.csproj @@ -0,0 +1,5 @@ + + + + + diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs index 077c542b..8d595adc 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideProcessorTest.cs @@ -12,6 +12,7 @@ using Trash.Radarr.CustomFormat.Guide; using Trash.Radarr.CustomFormat.Models; using Trash.Radarr.CustomFormat.Processors; using Trash.Radarr.CustomFormat.Processors.GuideSteps; +using Trash.TestLibrary; namespace Trash.Tests.Radarr.CustomFormat.Processors { @@ -144,30 +145,26 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors }); guideProcessor.ProfileScores.Should() - .BeEquivalentTo(new Dictionary> + .BeEquivalentTo(new Dictionary { { - "profile1", new List - { - new(expectedProcessedCustomFormatData[0], 500), - new(expectedProcessedCustomFormatData[1], 480) - } + "profile1", CfTestUtils.NewMapping( + new FormatMappingEntry(expectedProcessedCustomFormatData[0], 500), + new FormatMappingEntry(expectedProcessedCustomFormatData[1], 480)) }, { - "profile2", new List - { - new(expectedProcessedCustomFormatData[0], -1234), - new(expectedProcessedCustomFormatData[1], -1234), - new(expectedProcessedCustomFormatData[2], -1234) - } + "profile2", CfTestUtils.NewMapping( + new FormatMappingEntry(expectedProcessedCustomFormatData[0], -1234), + new FormatMappingEntry(expectedProcessedCustomFormatData[1], -1234), + new FormatMappingEntry(expectedProcessedCustomFormatData[2], -1234)) }, { - "profile4", new List - { - new(expectedProcessedCustomFormatData[2], 5678) - } + "profile4", CfTestUtils.NewMapping( + new FormatMappingEntry(expectedProcessedCustomFormatData[2], 5678)) } - }, op => op.Using(new JsonEquivalencyStep())); + }, op => op + .Using(new JsonEquivalencyStep()) + .ComparingByMembers()); } } } diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs index d02fc95a..06d9f1b8 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStepTest.cs @@ -5,6 +5,7 @@ using NUnit.Framework; using Trash.Radarr; using Trash.Radarr.CustomFormat.Models; using Trash.Radarr.CustomFormat.Processors.GuideSteps; +using Trash.TestLibrary; namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps { @@ -58,11 +59,9 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps var processor = new QualityProfileStep(); processor.Process(testConfigData); - processor.ProfileScores.Should().ContainKey("profile1") - .WhichValue.Should().BeEquivalentTo(new List - { - new(testConfigData[0].CustomFormats[0], 50) - }); + processor.ProfileScores.Should() + .ContainKey("profile1").WhichValue.Should() + .BeEquivalentTo(CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats[0], 50))); processor.CustomFormatsWithoutScore.Should().BeEmpty(); } @@ -89,13 +88,11 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps var processor = new QualityProfileStep(); processor.Process(testConfigData); - var expectedScoreEntries = new List - { - new(testConfigData[0].CustomFormats[0], 100) - }; + var expectedScoreEntries = + CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats[0], 100)); processor.ProfileScores.Should().BeEquivalentTo( - new Dictionary> + new Dictionary { {"profile1", expectedScoreEntries}, {"profile2", expectedScoreEntries} @@ -125,11 +122,9 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps var processor = new QualityProfileStep(); processor.Process(testConfigData); - processor.ProfileScores.Should().ContainKey("profile1") - .WhichValue.Should().BeEquivalentTo(new List - { - new(testConfigData[0].CustomFormats[0], 0) - }); + processor.ProfileScores.Should() + .ContainKey("profile1").WhichValue.Should() + .BeEquivalentTo(CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats[0], 0))); processor.CustomFormatsWithoutScore.Should().BeEmpty(); } diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs index f5e00129..3331c75f 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceProcessorTest.cs @@ -29,7 +29,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors var guideCfs = Array.Empty(); var deletedCfsInCache = new Collection(); - var profileScores = new Dictionary>(); + var profileScores = new Dictionary(); var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps); processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores); @@ -49,7 +49,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors var guideCfs = Array.Empty(); var deletedCfsInCache = Array.Empty(); - var profileScores = new Dictionary>(); + var profileScores = new Dictionary(); var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps); processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores); @@ -68,7 +68,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors var guideCfs = Array.Empty(); var deletedCfsInCache = Array.Empty(); - var profileScores = new Dictionary>(); + var profileScores = new Dictionary(); var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps); diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs index 411d7652..4e7fa162 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStepTest.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Linq; using FluentAssertions; using FluentAssertions.Json; using Newtonsoft.Json; @@ -11,6 +10,7 @@ using Trash.Radarr.CustomFormat.Api; using Trash.Radarr.CustomFormat.Models; using Trash.Radarr.CustomFormat.Models.Cache; using Trash.Radarr.CustomFormat.Processors.PersistenceSteps; +using Trash.TestLibrary; namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps { @@ -31,7 +31,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps { 'format': 2, 'name': 'cf2', - 'score': 2 + 'score': 0 }, { 'format': 3, @@ -45,16 +45,14 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps var api = Substitute.For(); api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); - var cfScores = new Dictionary> + var cfScores = new Dictionary { { - "profile1", new List - { - new(new ProcessedCustomFormatData("", "", new JObject()) + "profile1", CfTestUtils.NewMapping( + new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) { CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4} - }, 100) - } + }, 100)) } }; @@ -72,19 +70,74 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps var api = Substitute.For(); api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); - var cfScores = new Dictionary> + var cfScores = new Dictionary { - {"wrong_profile_name", new List()} + {"wrong_profile_name", CfTestUtils.NewMapping()} }; var processor = new QualityProfileApiPersistenceStep(); processor.Process(api, cfScores); - api.DidNotReceive().UpdateQualityProfile(Arg.Any(), Arg.Any()); - processor.InvalidProfileNames.Should().BeEquivalentTo("wrong_profile_name"); + processor.InvalidProfileNames.Should().Equal("wrong_profile_name"); processor.UpdatedScores.Should().BeEmpty(); } + [Test] + public void Reset_scores_for_unmatched_cfs_if_enabled() + { + const string radarrQualityProfileData = @"[{ + 'name': 'profile1', + 'formatItems': [{ + 'format': 1, + 'name': 'cf1', + 'score': 1 + }, + { + 'format': 2, + 'name': 'cf2', + 'score': 50 + }, + { + 'format': 3, + 'name': 'cf3', + 'score': 3 + } + ], + 'id': 1 +}]"; + + var api = Substitute.For(); + api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); + + var cfScores = new Dictionary + { + { + "profile1", CfTestUtils.NewMappingWithReset( + new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) + { + CacheEntry = new TrashIdMapping("", "", 2) + }, 100)) + } + }; + + var processor = new QualityProfileApiPersistenceStep(); + processor.Process(api, cfScores); + + processor.InvalidProfileNames.Should().BeEmpty(); + processor.UpdatedScores.Should() + .ContainKey("profile1").WhichValue.Should() + .BeEquivalentTo(new List + { + new("cf1", 0, FormatScoreUpdateReason.Reset), + new("cf2", 100, FormatScoreUpdateReason.Updated), + new("cf3", 0, FormatScoreUpdateReason.Reset) + }); + + api.Received().UpdateQualityProfile( + Verify.That(j => j["formatItems"].Children().Should().HaveCount(3)), + Arg.Any()); + } + [Test] public void Scores_are_set_in_quality_profile() { @@ -132,27 +185,25 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps var api = Substitute.For(); api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject>(radarrQualityProfileData)); - var cfScores = new Dictionary> + var cfScores = new Dictionary { { - "profile1", new List - { - new(new ProcessedCustomFormatData("", "", new JObject()) + "profile1", CfTestUtils.NewMapping( + new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) { // First match by ID - CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 4} + CacheEntry = new TrashIdMapping("", "", 4) }, 100), - new(new ProcessedCustomFormatData("", "", new JObject()) + new FormatMappingEntry(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()) + new FormatMappingEntry(new ProcessedCustomFormatData("", "", new JObject()) { // Second match by ID - CacheEntry = new TrashIdMapping("", "") {CustomFormatId = 1} - }, 102) - } + CacheEntry = new TrashIdMapping("", "", 1) + }, 102)) } }; @@ -203,9 +254,13 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps api.Received() .UpdateQualityProfile(Verify.That(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]); + processor.UpdatedScores.Should() + .ContainKey("profile1").WhichValue.Should() + .BeEquivalentTo(new List + { + new("3D", 100, FormatScoreUpdateReason.Updated), + new("asdf2", 102, FormatScoreUpdateReason.Updated) + }); } } } diff --git a/src/Trash.Tests/Trash.Tests.csproj b/src/Trash.Tests/Trash.Tests.csproj index 15dfca36..9f984b62 100644 --- a/src/Trash.Tests/Trash.Tests.csproj +++ b/src/Trash.Tests/Trash.Tests.csproj @@ -5,6 +5,7 @@ + diff --git a/src/Trash/Extensions/DictionaryExtensions.cs b/src/Trash/Extensions/DictionaryExtensions.cs index 918992f5..a42a1193 100644 --- a/src/Trash/Extensions/DictionaryExtensions.cs +++ b/src/Trash/Extensions/DictionaryExtensions.cs @@ -16,16 +16,9 @@ namespace Trash.Extensions return val; } - public static TValue GetOrDefault(this IDictionary dict, TKey key) - where TValue : struct + public static TValue? GetOrDefault(this IDictionary dict, TKey key) { - if (!dict.TryGetValue(key, out var val)) - { - val = default; - dict.Add(key, val); - } - - return val; + return dict.TryGetValue(key, out var val) ? val : default; } } } diff --git a/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs index afacf05f..318abf1e 100644 --- a/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs +++ b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs @@ -69,9 +69,9 @@ namespace Trash.Radarr.CustomFormat { Log.Debug("> Scores updated for quality profile: {ProfileName}", profileName); - foreach (var score in scores) + foreach (var (customFormatName, score, reason) in scores) { - Log.Debug(" - {Format}: {Score}", score.CustomFormat.Name, score.Score); + Log.Debug(" - {Format}: {Score} ({Reason})", customFormatName, score, reason); } } @@ -249,23 +249,23 @@ namespace Trash.Radarr.CustomFormat Console.WriteLine(profileFormat, "Profile", "Custom Format", "Score"); Console.WriteLine(string.Concat(Enumerable.Repeat('-', 2 + 18 + 20 + 8))); - foreach (var (profileName, scoreEntries) in _guideProcessor.ProfileScores) + foreach (var (profileName, scoreMap) in _guideProcessor.ProfileScores) { Console.WriteLine(profileFormat, profileName, "", ""); - foreach (var scoreEntry in scoreEntries) + foreach (var (customFormat, score) in scoreMap.Mapping) { var matchingCf = _guideProcessor.ProcessedCustomFormats - .FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(scoreEntry.CustomFormat.TrashId)); + .FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(customFormat.TrashId)); if (matchingCf == null) { Log.Warning("Quality Profile refers to CF not found in guide: {TrashId}", - scoreEntry.CustomFormat.TrashId); + customFormat.TrashId); continue; } - Console.WriteLine(profileFormat, "", matchingCf.Name, scoreEntry.Score); + Console.WriteLine(profileFormat, "", matchingCf.Name, score); } } diff --git a/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs b/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs deleted file mode 100644 index 48ce86ca..00000000 --- a/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreEntry.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace Trash.Radarr.CustomFormat.Models -{ - public class QualityProfileCustomFormatScoreEntry - { - public QualityProfileCustomFormatScoreEntry(ProcessedCustomFormatData customFormat, int score) - { - CustomFormat = customFormat; - Score = score; - } - - public ProcessedCustomFormatData CustomFormat { get; } - public int Score { get; } - } -} diff --git a/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreMapping.cs b/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreMapping.cs new file mode 100644 index 00000000..08c0f869 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/QualityProfileCustomFormatScoreMapping.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace Trash.Radarr.CustomFormat.Models +{ + public record FormatMappingEntry(ProcessedCustomFormatData CustomFormat, int Score); + + public class QualityProfileCustomFormatScoreMapping + { + public QualityProfileCustomFormatScoreMapping(bool resetUnmatchedScores) + { + ResetUnmatchedScores = resetUnmatchedScores; + } + + public bool ResetUnmatchedScores { get; } + public List Mapping { get; init; } = new(); + } +} diff --git a/src/Trash/Radarr/CustomFormat/Models/UpdatedFormatScore.cs b/src/Trash/Radarr/CustomFormat/Models/UpdatedFormatScore.cs new file mode 100644 index 00000000..9e850584 --- /dev/null +++ b/src/Trash/Radarr/CustomFormat/Models/UpdatedFormatScore.cs @@ -0,0 +1,13 @@ +namespace Trash.Radarr.CustomFormat.Models +{ + public enum FormatScoreUpdateReason + { + Updated, + Reset + } + + public record UpdatedFormatScore( + string CustomFormatName, + int Score, + FormatScoreUpdateReason Reason); +} diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs index 24e087a4..16046f0f 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideProcessor.cs @@ -43,7 +43,7 @@ namespace Trash.Radarr.CustomFormat.Processors public IReadOnlyCollection ConfigData => _steps.Config.ConfigData; - public IDictionary> ProfileScores + public IDictionary ProfileScores => _steps.QualityProfile.ProfileScores; public IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs index f974b923..c9a25120 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/IQualityProfileStep.cs @@ -5,7 +5,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps { public interface IQualityProfileStep { - Dictionary> ProfileScores { get; } + Dictionary ProfileScores { get; } List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } void Process(IEnumerable configData); } diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs index 46b35ba3..81a9cc57 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/QualityProfileStep.cs @@ -1,12 +1,11 @@ using System.Collections.Generic; -using Trash.Extensions; using Trash.Radarr.CustomFormat.Models; namespace Trash.Radarr.CustomFormat.Processors.GuideSteps { public class QualityProfileStep : IQualityProfileStep { - public Dictionary> ProfileScores { get; } = new(); + public Dictionary ProfileScores { get; } = new(); public List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } = new(); public void Process(IEnumerable configData) @@ -18,7 +17,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps // 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. + // 3. Warn the user and var scoreToUse = profile.Score; if (scoreToUse == null) { @@ -32,11 +31,18 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps } } - if (scoreToUse != null) + if (scoreToUse == null) { - ProfileScores.GetOrCreate(profile.Name) - .Add(new QualityProfileCustomFormatScoreEntry(cf, scoreToUse.Value)); + continue; } + + if (!ProfileScores.TryGetValue(profile.Name, out var mapping)) + { + mapping = new QualityProfileCustomFormatScoreMapping(profile.ResetUnmatchedScores); + ProfileScores[profile.Name] = mapping; + } + + mapping.Mapping.Add(new FormatMappingEntry(cf, scoreToUse.Value)); } } } diff --git a/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs index c8d1da08..9c2ddf72 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/IGuideProcessor.cs @@ -10,7 +10,7 @@ namespace Trash.Radarr.CustomFormat.Processors IReadOnlyCollection ProcessedCustomFormats { get; } IReadOnlyCollection CustomFormatsNotInGuide { get; } IReadOnlyCollection ConfigData { get; } - IDictionary> ProfileScores { get; } + IDictionary ProfileScores { get; } IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } IReadOnlyCollection DeletedCustomFormatsInCache { get; } List<(string, string)> CustomFormatsWithOutdatedNames { get; } diff --git a/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs index d21cf70a..ef0abcb3 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/IPersistenceProcessor.cs @@ -8,13 +8,13 @@ namespace Trash.Radarr.CustomFormat.Processors { public interface IPersistenceProcessor { - IDictionary> UpdatedScores { get; } + IDictionary> UpdatedScores { get; } IReadOnlyCollection InvalidProfileNames { get; } CustomFormatTransactionData Transactions { get; } Task PersistCustomFormats(IReadOnlyCollection guideCfs, IEnumerable deletedCfsInCache, - IDictionary> profileScores); + IDictionary profileScores); void Reset(); } diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs index 8a92c1df..caea4441 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceProcessor.cs @@ -40,7 +40,7 @@ namespace Trash.Radarr.CustomFormat.Processors public CustomFormatTransactionData Transactions => _steps.JsonTransactionStep.Transactions; - public IDictionary> UpdatedScores + public IDictionary> UpdatedScores => _steps.ProfileQualityProfileApiPersister.UpdatedScores; public IReadOnlyCollection InvalidProfileNames @@ -53,7 +53,7 @@ namespace Trash.Radarr.CustomFormat.Processors public async Task PersistCustomFormats(IReadOnlyCollection guideCfs, IEnumerable deletedCfsInCache, - IDictionary> profileScores) + IDictionary profileScores) { var radarrCfs = await _customFormatService.GetCustomFormats(); diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs index 40f238ac..0961fb0c 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/IQualityProfileApiPersistenceStep.cs @@ -7,10 +7,10 @@ namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps { public interface IQualityProfileApiPersistenceStep { - IDictionary> UpdatedScores { get; } + IDictionary> UpdatedScores { get; } IReadOnlyCollection InvalidProfileNames { get; } Task Process(IQualityProfileService api, - IDictionary> cfScores); + IDictionary cfScores); } } diff --git a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs index d5ac8576..fe0110cc 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/PersistenceSteps/QualityProfileApiPersistenceStep.cs @@ -12,45 +12,60 @@ namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps public class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceStep { private readonly List _invalidProfileNames = new(); - private readonly Dictionary> _updatedScores = new(); + private readonly Dictionary> _updatedScores = new(); - public IDictionary> UpdatedScores => _updatedScores; + public IDictionary> UpdatedScores => _updatedScores; public IReadOnlyCollection InvalidProfileNames => _invalidProfileNames; public async Task Process(IQualityProfileService api, - IDictionary> cfScores) + IDictionary cfScores) { - var radarrProfiles = (await api.GetQualityProfiles()) - .Select(p => (Name: p["name"].ToString(), Json: p)); + var radarrProfiles = await api.GetQualityProfiles(); - var profileScores = cfScores - .GroupJoin(radarrProfiles, - s => s.Key, - p => p.Name, - (s, pList) => (s.Key, s.Value, - pList.SelectMany(p => p.Json["formatItems"].Children()).ToList()), - StringComparer.InvariantCultureIgnoreCase); + // Match quality profiles in Radarr to ones the user put in their config. + // 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 profileScores = cfScores.GroupJoin(radarrProfiles, + s => s.Key, + p => (string) p["name"], + (s, p) => (s.Key, s.Value, p.SelectMany(pi => pi["formatItems"].Children()).ToList()), + StringComparer.InvariantCultureIgnoreCase); - foreach (var (profileName, scoreList, jsonList) in profileScores) + foreach (var (profileName, scoreMap, formatItems) in profileScores) { - if (jsonList.Count == 0) + if (formatItems.Count == 0) { _invalidProfileNames.Add(profileName); continue; } - foreach (var (score, json) in scoreList - .Select(s => (s, FindJsonScoreEntry(s, jsonList))) - .Where(p => p.Item2 != null)) + foreach (var json in formatItems) { - var currentScore = (int) json!["score"]; - if (currentScore == score.Score) + var map = FindScoreEntry(json, scoreMap); + + int? scoreToUse = null; + FormatScoreUpdateReason? reason = null; + + if (map != null) + { + scoreToUse = map.Score; + reason = FormatScoreUpdateReason.Updated; + } + else if (scoreMap.ResetUnmatchedScores) + { + scoreToUse = 0; + reason = FormatScoreUpdateReason.Reset; + } + + if (scoreToUse == null || reason == null || (int) json["score"] == scoreToUse) { continue; } - json!["score"] = score.Score; - _updatedScores.GetOrCreate(profileName).Add(score); + json!["score"] = scoreToUse.Value; + _updatedScores.GetOrCreate(profileName) + .Add(new UpdatedFormatScore((string) json["name"], scoreToUse.Value, reason.Value)); } if (!_updatedScores.TryGetValue(profileName, out var updatedScores) || updatedScores.Count == 0) @@ -59,17 +74,17 @@ namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps continue; } - var jsonRoot = (JObject) jsonList.First().Root; + var jsonRoot = (JObject) formatItems.First().Root; await api.UpdateQualityProfile(jsonRoot, (int) jsonRoot["id"]); } } - private static JObject? FindJsonScoreEntry(QualityProfileCustomFormatScoreEntry score, - IEnumerable jsonList) + private static FormatMappingEntry? FindScoreEntry(JObject formatItem, + QualityProfileCustomFormatScoreMapping scoreMap) { - return jsonList.FirstOrDefault(j - => score.CustomFormat.CacheEntry != null && - (int) j["format"] == score.CustomFormat.CacheEntry.CustomFormatId); + return scoreMap.Mapping.FirstOrDefault( + m => m.CustomFormat.CacheEntry != null && + (int) formatItem["format"] == m.CustomFormat.CacheEntry.CustomFormatId); } } } diff --git a/src/Trash/Radarr/RadarrConfiguration.cs b/src/Trash/Radarr/RadarrConfiguration.cs index dac72636..75958c76 100644 --- a/src/Trash/Radarr/RadarrConfiguration.cs +++ b/src/Trash/Radarr/RadarrConfiguration.cs @@ -50,6 +50,7 @@ namespace Trash.Radarr public string Name { get; init; } = ""; public int? Score { get; init; } + public bool ResetUnmatchedScores { get; init; } } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] diff --git a/src/Trash/trash-config-template.yml b/src/Trash/trash-config-template.yml index 2c56732f..376d88c5 100644 --- a/src/Trash/trash-config-template.yml +++ b/src/Trash/trash-config-template.yml @@ -54,3 +54,4 @@ radarr: # - name: Quality Profile 1 # - name: Quality Profile 2 # #score: -9999 # Optional score to assign to all CFs. Overrides scores in the guide. +# #reset_unmatched_scores: true # Optionally set other scores to 0 if they are not listed in 'names' above. diff --git a/src/TrashUpdater.sln b/src/TrashUpdater.sln index 7001833a..ca9b80fa 100644 --- a/src/TrashUpdater.sln +++ b/src/TrashUpdater.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common", "Common\Common.csp EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Common.Tests", "Common.Tests\Common.Tests.csproj", "{0720939D-1CA6-43D7-BBED-F8F894C4F562}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Trash.TestLibrary", "Trash.TestLibrary\Trash.TestLibrary.csproj", "{33226068-65E3-4890-8671-59A56BA3F6F0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -49,6 +51,10 @@ Global {0720939D-1CA6-43D7-BBED-F8F894C4F562}.Debug|Any CPU.Build.0 = Debug|Any CPU {0720939D-1CA6-43D7-BBED-F8F894C4F562}.Release|Any CPU.ActiveCfg = Release|Any CPU {0720939D-1CA6-43D7-BBED-F8F894C4F562}.Release|Any CPU.Build.0 = Release|Any CPU + {33226068-65E3-4890-8671-59A56BA3F6F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33226068-65E3-4890-8671-59A56BA3F6F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33226068-65E3-4890-8671-59A56BA3F6F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33226068-65E3-4890-8671-59A56BA3F6F0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution EndGlobalSection diff --git a/wiki/Configuration-Examples.md b/wiki/Configuration-Examples.md index 16c9cfcf..76289b40 100644 --- a/wiki/Configuration-Examples.md +++ b/wiki/Configuration-Examples.md @@ -7,6 +7,7 @@ Various scenarios supported using flexible configuration structure: - [Manually assign different scores to multiple custom formats](#manually-assign-different-scores-to-multiple-custom-formats) - [Assign custom format scores the same way to multiple quality profiles](#assign-custom-format-scores-the-same-way-to-multiple-quality-profiles) - [Resolving ambiguity between custom formats with the same name](#resolving-ambiguity-between-custom-formats-with-the-same-name) +- [Scores in a quality profile should be set to zero if it wasn't listed in config](#scores-in-a-quality-profile-should-be-set-to-zero-if-it-wasnt-listed-in-config) ## Update as much as possible in both Sonarr and Radarr with a single config @@ -120,10 +121,8 @@ update at the same time. There's an example of how to do that in a different sec ## Synchronize a lot of custom formats for a single quality profile -Scenario: - -"I want to be able to synchronize a list of custom formats to Radarr. In addition, I want the scores -in the guide to be applied to a single quality profile." +Scenario: *"I want to be able to synchronize a list of custom formats to Radarr. In addition, I want +the scores in the guide to be applied to a single quality profile."* Solution: @@ -155,11 +154,9 @@ radarr: ## Manually assign different scores to multiple custom formats -Scenario: - -"I want to synchronize custom formats to Radarr. I also do not want to use the scores in +Scenario: *"I want to synchronize custom formats to Radarr. I also do not want to use the scores in the guide. Instead, I want to assign my own distinct score to each custom format in a single quality -profile." +profile."* Solution: @@ -279,3 +276,42 @@ radarr: Where do you get the Trash ID? That's from the `"trash_id"` property of the actual JSON for the custom format in the guide. + +## Scores in a quality profile should be set to zero if it wasn't listed in config + +Scenario: *"I completely rely on Trash Updater to set scores on my quality profiles. I never plan to +manually set scores on those profiles. If I alter which custom format scores get assigned to a +quality profile, the old scores should be set back to 0 automatically for me."* + +```yml +radarr: + - base_url: http://localhost:7878 + api_key: 87674e2c316645ed85696a91a3d41988 + + custom_formats: + - names: + - DTS X + - TrueHD + quality_profiles: + - name: SD + reset_unmatched_scores: true + - name: Ultra-HD +``` + +Let's say you have three custom formats added to Radarr: "DTS X", "TrueHD", and "DoVi". Since only +the first two are listed in the `names` array, what happens to "DoVi"? Since two quality profiles +are specified above, each with a different setting for `reset_unmatched_scores`, the behavior will +be different: + +- The `SD` profile will always have the score for "DoVi" set to zero (`0`). +- The `Ultra-HD` profile's score for "DoVi" will never be altered. + +The `reset_unmatched_scores` setting basically determines how scores are handled for custom formats +that exist in Radarr but are not in the list of `names` in config. As shown in the example above, +you set it to `true` which results in unmatched scores being set to `0`, or you can set it to +`false` (or leave it omitted) in which case Trash Updater will not alter the value. + +Which one should you use? That depends on how much control you want Trash Updater to have. If you +use Trash Updater to supplement manual changes to your profiles, you probably want it set to `false` +so it doesn't clobber your manual edits. Otherwise, set it to `true` so that scores aren't left over +when you add/remove custom formats from a profile. diff --git a/wiki/Configuration-Reference.md b/wiki/Configuration-Reference.md index 8f994699..57b65174 100644 --- a/wiki/Configuration-Reference.md +++ b/wiki/Configuration-Reference.md @@ -284,3 +284,9 @@ Synchronization]] page. A positive or negative number representing the score to apply to *all* custom formats listed in the `names` list. A score of `0` is also acceptable, which effectively disables the custom formats without having to delete them. + + - `reset_unmatched_scores` (Optional; *Default: `false`*)
+ If set to `true`, enables setting scores to `0` in quality profiles where either a name was + not mentioned in the `names` array *or* it was in that list but did not get a score (e.g. no + score in guide). If `false`, scores are never altered unless it is listed in the `names` array + *and* has a valid score to assign.