From 46675e38c51762899e24079c50b7dd85557800b1 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 16 May 2021 11:33:43 -0500 Subject: [PATCH] feat(radarr): add custom formats by trash_id There was an ambiguity with one custom format named 'DoVi'. This one had two custom formats in the guide. The intent was for the user to choose only one of these, but the name was kept identical so that name appeared in the filename for media when it was renamed. This allows the user to choose which of those two they want using the `trash_id` property. --- CHANGELOG.md | 2 + src/Directory.Build.targets | 2 +- .../Config/ConfigurationLoaderTest.cs | 5 + .../Processors/GuideSteps/ConfigStepTest.cs | 118 +++++++++++++----- .../GuideSteps/CustomFormatStepTest.cs | 69 ++++++---- .../Radarr/RadarrConfigurationTest.cs | 46 ++++++- .../CustomFormat/CustomFormatUpdater.cs | 6 +- .../Processors/GuideSteps/ConfigStep.cs | 57 ++++++--- .../Processors/GuideSteps/CustomFormatStep.cs | 28 +++-- .../GuideSteps/ICustomFormatStep.cs | 4 +- src/Trash/Radarr/RadarrConfiguration.cs | 25 ++-- src/Trash/Trash.csproj | 3 +- wiki/Configuration-Examples.md | 54 +++++++- wiki/Configuration-Reference.md | 53 +++++++- 14 files changed, 366 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0597d81..2bf866b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Custom formats can now be specified by Trash ID. This is useful for situations where two or more + custom formats in the guide have the same name (e.g. 'DoVi'). - Debug-level logs are now written to file in addition to the Info-level logs in console output. ## [1.4.2] - 2021-05-15 diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 7fb4a4bc..be04764d 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -6,7 +6,6 @@ - @@ -24,6 +23,7 @@ + diff --git a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs index 493dab0b..4f485500 100644 --- a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs +++ b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.IO; using System.IO.Abstractions; using System.Linq; @@ -28,6 +29,8 @@ namespace Trash.Tests.Config return new StringReader(testData.ReadData(file)); } + [SuppressMessage("Microsoft.Design", "CA1034", + Justification = "YamlDotNet requires this type to be public so it may access it")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class TestConfigValidFalse : IServiceConfiguration { @@ -47,6 +50,8 @@ namespace Trash.Tests.Config } } + [SuppressMessage("Microsoft.Design", "CA1034", + Justification = "YamlDotNet requires this type to be public so it may access it")] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class TestConfigValidTrue : IServiceConfiguration { diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs index 0efa68d4..1f64b5c4 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/ConfigStepTest.cs @@ -14,7 +14,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps public class ConfigStepTest { [Test] - public void All_custom_formats_found_in_guide() + public void Cache_names_are_used_instead_of_name_in_json_data() { var testProcessedCfs = new List { @@ -23,17 +23,16 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps Score = 100 }, new("name3", "id3", JObject.FromObject(new {name = "name3"})) + { + CacheEntry = new TrashIdMapping("id3", "name1") + } }; var testConfig = new CustomFormatConfig[] { new() { - Names = new List {"name1", "name3"}, - QualityProfiles = new List - { - new() {Name = "profile1", Score = 50} - } + Names = new List {"name1"} } }; @@ -45,8 +44,8 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps { new() { - CustomFormats = testProcessedCfs, - QualityProfiles = testConfig[0].QualityProfiles + CustomFormats = new List + {testProcessedCfs[1]} } }, op => op .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) @@ -54,18 +53,12 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps } [Test] - public void Cache_names_are_used_instead_of_name_in_json_data() + public void Custom_formats_missing_from_config_are_skipped() { var testProcessedCfs = new List { - new("name1", "id1", JObject.FromObject(new {name = "name1"})) - { - Score = 100 - }, - new("name3", "id3", JObject.FromObject(new {name = "name3"})) - { - CacheEntry = new TrashIdMapping("id3", "name1") - } + new("name1", "", new JObject()), + new("name2", "", new JObject()) }; var testConfig = new CustomFormatConfig[] @@ -85,7 +78,9 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps new() { CustomFormats = new List - {testProcessedCfs[1]} + { + new("name1", "", new JObject()) + } } }, op => op .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) @@ -93,7 +88,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps } [Test] - public void Custom_formats_missing_from_config_are_skipped() + public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list() { var testProcessedCfs = new List { @@ -105,14 +100,16 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps { new() { - Names = new List {"name1"} + Names = new List {"name1", "name3"} } }; var processor = new ConfigStep(); processor.Process(testProcessedCfs, testConfig); - processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List {"name3"}, op => op + .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) + .WhenTypeIs()); processor.ConfigData.Should().BeEquivalentTo(new List { new() @@ -128,37 +125,98 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps } [Test] - public void Custom_formats_missing_from_guide_are_added_to_not_in_guide_list() + public void Duplicate_config_name_and_id_are_ignored() { var testProcessedCfs = new List { - new("name1", "", new JObject()), - new("name2", "", new JObject()) + new("name1", "id1", new JObject()) }; var testConfig = new CustomFormatConfig[] { new() { - Names = new List {"name1", "name3"} + Names = new List {"name1"}, + TrashIds = new List {"id1"} } }; var processor = new ConfigStep(); processor.Process(testProcessedCfs, testConfig); - processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List {"name3"}, op => op - .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) - .WhenTypeIs()); + processor.CustomFormatsNotInGuide.Should().BeEmpty(); processor.ConfigData.Should().BeEquivalentTo(new List { new() { - CustomFormats = new List + CustomFormats = new List {testProcessedCfs[0]} + } + }); + } + + [Test] + public void Duplicate_config_names_are_ignored() + { + var testProcessedCfs = new List + { + new("name1", "id1", new JObject()) + }; + + var testConfig = new CustomFormatConfig[] + { + new() {Names = new List {"name1", "name1"}} + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = new List {testProcessedCfs[0]} + } + }); + } + + [Test] + public void Find_custom_formats_by_name_and_trash_id() + { + var testProcessedCfs = new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) + { + Score = 100 + }, + new("name3", "id3", JObject.FromObject(new {name = "name3"})), + new("name4", "id4", new JObject()) + }; + + var testConfig = new CustomFormatConfig[] + { + new() + { + Names = new List {"name1", "name3"}, + TrashIds = new List {"id1", "id4"}, + QualityProfiles = new List { - new("name1", "", new JObject()) + new() {Name = "profile1", Score = 50} } } + }; + + var processor = new ConfigStep(); + processor.Process(testProcessedCfs, testConfig); + + processor.CustomFormatsNotInGuide.Should().BeEmpty(); + processor.ConfigData.Should().BeEquivalentTo(new List + { + new() + { + CustomFormats = testProcessedCfs, + QualityProfiles = testConfig[0].QualityProfiles + } }, op => op .Using(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation)) .WhenTypeIs()); diff --git a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs index f6fe1478..badb58dd 100644 --- a/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs +++ b/src/Trash.Tests/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStepTest.cs @@ -284,34 +284,41 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps } [Test] - public void Match_cf_names_regardless_of_case_in_config() + public void Duplicates_are_recorded_and_removed_from_processed_custom_formats_list() { - var ctx = new Context(); + var guideData = new List + { + new() {Json = @"{'name': 'name1', 'trash_id': 'id1'}"}, + new() {Json = @"{'name': 'name1', 'trash_id': 'id2'}"} + }; + var testConfig = new List { - new() {Names = new List {"name1", "NAME1"}} + new() {Names = new List {"name1"}} }; var processor = new CustomFormatStep(); - processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + processor.Process(guideData, testConfig, null); - processor.DuplicatedCustomFormats.Should().BeEmpty(); + //Dictionary> + processor.DuplicatedCustomFormats.Should().ContainKey("name1") + .WhichValue.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.Parse(@"{'name': 'name1'}")), + new("name1", "id2", JObject.Parse(@"{'name': 'name1'}")) + }); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty(); - processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List - { - new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100} - }, - op => op.Using(new JsonEquivalencyStep())); + processor.ProcessedCustomFormats.Should().BeEmpty(); } [Test] - public void Non_existent_cfs_in_config_are_skipped() + public void Match_cf_names_regardless_of_case_in_config() { var ctx = new Context(); var testConfig = new List { - new() {Names = new List {"doesnt_exist"}} + new() {Names = new List {"name1", "NAME1"}} }; var processor = new CustomFormatStep(); @@ -320,33 +327,53 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty(); - processor.ProcessedCustomFormats.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should().BeEquivalentTo(new List + { + new("name1", "id1", JObject.FromObject(new {name = "name1"})) {Score = 100} + }, + op => op.Using(new JsonEquivalencyStep())); } [Test] - public void Duplicates_are_recorded_and_removed_from_processed_custom_formats_list() + public void Match_custom_format_using_trash_id() { var guideData = new List { new() {Json = @"{'name': 'name1', 'trash_id': 'id1'}"}, - new() {Json = @"{'name': 'name1', 'trash_id': 'id2'}"} + new() {Json = @"{'name': 'name2', 'trash_id': 'id2'}"} }; var testConfig = new List { - new() {Names = new List {"name1"}} + new() {TrashIds = new List {"id2"}} }; var processor = new CustomFormatStep(); processor.Process(guideData, testConfig, null); - //Dictionary> - processor.DuplicatedCustomFormats.Should().ContainKey("name1") - .WhichValue.Should().BeEquivalentTo(new List + processor.DuplicatedCustomFormats.Should().BeEmpty(); + processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); + processor.DeletedCustomFormatsInCache.Should().BeEmpty(); + processor.ProcessedCustomFormats.Should() + .BeEquivalentTo(new List { - new ("name1", "id1", JObject.Parse(@"{'name': 'name1'}")), - new ("name1", "id2", JObject.Parse(@"{'name': 'name1'}")) + new("name2", "id2", JObject.FromObject(new {name = "name2"})) }); + } + + [Test] + public void Non_existent_cfs_in_config_are_skipped() + { + var ctx = new Context(); + var testConfig = new List + { + new() {Names = new List {"doesnt_exist"}} + }; + + var processor = new CustomFormatStep(); + processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache()); + + processor.DuplicatedCustomFormats.Should().BeEmpty(); processor.CustomFormatsWithOutdatedNames.Should().BeEmpty(); processor.DeletedCustomFormatsInCache.Should().BeEmpty(); processor.ProcessedCustomFormats.Should().BeEmpty(); diff --git a/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs b/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs index dea2aa64..1e2669fd 100644 --- a/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs +++ b/src/Trash.Tests/Radarr/RadarrConfigurationTest.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.IO; using System.IO.Abstractions; using FluentAssertions; @@ -15,10 +16,47 @@ namespace Trash.Tests.Radarr [Parallelizable(ParallelScope.All)] public class RadarrConfigurationTest { + public static IEnumerable GetTrashIdsOrNamesEmptyTestData() + { + yield return new TestCaseData(@" +radarr: + - api_key: abc + base_url: xyz + custom_formats: + - names: [foo] + quality_profiles: + - name: MyProfile +") + .SetName("{m} (without_trash_ids)"); + + yield return new TestCaseData(@" +radarr: + - api_key: abc + base_url: xyz + custom_formats: + - trash_ids: [abc123] + quality_profiles: + - name: MyProfile +") + .SetName("{m} (without_names)"); + } + + [TestCaseSource(nameof(GetTrashIdsOrNamesEmptyTestData))] + public void Custom_format_either_names_or_trash_id_not_empty_is_ok(string testYaml) + { + var configLoader = new ConfigurationLoader( + Substitute.For(), + Substitute.For(), new DefaultObjectFactory()); + + Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr"); + + act.Should().NotThrow(); + } + [Test] - public void Custom_format_names_list_is_required() + public void Custom_format_names_and_trash_ids_lists_must_not_both_be_empty() { - const string testYaml = @" + var testYaml = @" radarr: - api_key: abc base_url: xyz @@ -26,14 +64,14 @@ radarr: - quality_profiles: - name: MyProfile "; - var configLoader = new ConfigurationLoader( Substitute.For(), Substitute.For(), new DefaultObjectFactory()); Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr"); - act.Should().Throw(); + act.Should().Throw() + .WithMessage("*must contain at least one element in either 'names' or 'trash_ids'."); } [Test] diff --git a/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs index 973e86ee..bb88367f 100644 --- a/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs +++ b/src/Trash/Radarr/CustomFormat/CustomFormatUpdater.cs @@ -142,9 +142,9 @@ namespace Trash.Radarr.CustomFormat if (_guideProcessor.DuplicatedCustomFormats.Count > 0) { Log.Warning("One or more of the custom formats you want are duplicated in the guide. These custom " + - "formats WILL BE SKIPPED. Radarr requires custom formats names to be unique. Trash Updater " + - "is not able to choose which one you actually wanted. This is a bug in the guide and you " + - "should request that it be fixed"); + "formats WILL BE SKIPPED. Trash Updater is not able to choose which one you actually " + + "wanted. To resolve this ambiguity, use the `trash_ids` property in your YML " + + "configuration to refer to the custom format using its Trash ID instead of its name"); foreach (var (cfName, dupes) in _guideProcessor.DuplicatedCustomFormats) { diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs index 1cb8a910..388f69aa 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ConfigStep.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using MoreLinq.Extensions; using Trash.Extensions; using Trash.Radarr.CustomFormat.Models; @@ -14,31 +15,53 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps public void Process(IReadOnlyCollection processedCfs, IEnumerable config) { - foreach (var configCf in config) + foreach (var singleConfig in config) { - // Also get the list of CFs that are in the guide - var cfsInGuide = configCf.Names - .ToLookup(n => + var validCfs = new List(); + + foreach (var name in singleConfig.Names) + { + var match = FindCustomFormatByName(processedCfs, name); + if (match == null) + { + CustomFormatsNotInGuide.Add(name); + } + else { - // 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)); - }); + validCfs.Add(match); + } + } - // Names grouped under 'null' were not found in the guide OR the cache - CustomFormatsNotInGuide.AddRange( - cfsInGuide[null].Distinct(StringComparer.CurrentCultureIgnoreCase)); + foreach (var trashId in singleConfig.TrashIds) + { + var match = processedCfs.FirstOrDefault(cf => cf.TrashId.EqualsIgnoreCase(trashId)); + if (match == null) + { + CustomFormatsNotInGuide.Add(trashId); + } + else + { + validCfs.Add(match); + } + } ConfigData.Add(new ProcessedConfigData { - CustomFormats = cfsInGuide.Where(grp => grp.Key != null).Select(grp => grp.Key!).ToList(), - QualityProfiles = configCf.QualityProfiles + QualityProfiles = singleConfig.QualityProfiles, + CustomFormats = validCfs + .DistinctBy(cf => cf.TrashId, StringComparer.InvariantCultureIgnoreCase) + .ToList() }); } } + + private static ProcessedCustomFormatData? FindCustomFormatByName( + IReadOnlyCollection processedCfs, string name) + { + return processedCfs.FirstOrDefault( + cf => cf.CacheEntry?.CustomFormatName.EqualsIgnoreCase(name) ?? false) ?? + processedCfs.FirstOrDefault( + cf => cf.Name.EqualsIgnoreCase(name)); + } } } diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs index c4b2034c..8564a9b8 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/CustomFormatStep.cs @@ -18,16 +18,29 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps public Dictionary> DuplicatedCustomFormats { get; private set; } = new(); - public void Process(IEnumerable customFormatGuideData, IEnumerable config, - CustomFormatCache? cache) + public void Process(IEnumerable customFormatGuideData, + IReadOnlyCollection config, CustomFormatCache? cache) { + var processedCfs = customFormatGuideData + .Select(cf => ProcessCustomFormatData(cf, cache)) + .ToList(); + + // For each ID listed under the `trash_ids` YML property, match it to an existing CF + ProcessedCustomFormats.AddRange(config + .SelectMany(c => c.TrashIds) + .Distinct(StringComparer.CurrentCultureIgnoreCase) + .Join(processedCfs, + id => id, + cf => cf.TrashId, + (_, cf) => cf, + StringComparer.InvariantCultureIgnoreCase)); + + // Build a list of CF names under the `names` property in YAML. Exclude any names that + // are already provided by the `trash_ids` property. var allConfigCfNames = config .SelectMany(c => c.Names) .Distinct(StringComparer.CurrentCultureIgnoreCase) - .ToList(); - - var processedCfs = customFormatGuideData - .Select(cf => ProcessCustomFormatData(cf, cache)) + .Where(n => !ProcessedCustomFormats.Any(cf => cf.CacheAwareName.EqualsIgnoreCase(n))) .ToList(); // Perform updates and deletions based on matches in the cache. Matches in the cache are by ID. @@ -60,8 +73,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps } // 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(). + // removed it from their config. This will get marked for deletion later. } // Orphaned entries in cache represent custom formats we need to delete. diff --git a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs index e8db537a..4bc3db36 100644 --- a/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs +++ b/src/Trash/Radarr/CustomFormat/Processors/GuideSteps/ICustomFormatStep.cs @@ -12,7 +12,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps List<(string, string)> CustomFormatsWithOutdatedNames { get; } Dictionary> DuplicatedCustomFormats { get; } - void Process(IEnumerable customFormatGuideData, IEnumerable config, - CustomFormatCache? cache); + void Process(IEnumerable customFormatGuideData, + IReadOnlyCollection config, CustomFormatCache? cache); } } diff --git a/src/Trash/Radarr/RadarrConfiguration.cs b/src/Trash/Radarr/RadarrConfiguration.cs index 7b12f302..dac72636 100644 --- a/src/Trash/Radarr/RadarrConfiguration.cs +++ b/src/Trash/Radarr/RadarrConfiguration.cs @@ -1,10 +1,10 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using Flurl; using JetBrains.Annotations; using Trash.Config; using Trash.Radarr.QualityDefinition; -using Trash.YamlDotNet; namespace Trash.Radarr { @@ -12,8 +12,8 @@ namespace Trash.Radarr public class RadarrConfiguration : ServiceConfiguration { public QualityDefinitionConfig? QualityDefinition { get; init; } - public List CustomFormats { get; set; } = new(); - public bool DeleteOldCustomFormats { get; set; } + public List CustomFormats { get; init; } = new(); + public bool DeleteOldCustomFormats { get; init; } public override string BuildUrl() { @@ -24,6 +24,12 @@ namespace Trash.Radarr public override bool IsValid(out string msg) { + if (CustomFormats.Any(cf => cf.TrashIds.Count + cf.Names.Count == 0)) + { + msg = "'custom_formats' elements must contain at least one element in either 'names' or 'trash_ids'."; + return false; + } + msg = ""; return true; } @@ -32,19 +38,18 @@ namespace Trash.Radarr [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class CustomFormatConfig { - [CannotBeEmpty] - public List Names { get; set; } = new(); - - public List QualityProfiles { get; set; } = new(); + public List Names { get; init; } = new(); + public List TrashIds { get; init; } = new(); + public List QualityProfiles { get; init; } = new(); } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public class QualityProfileConfig { - [Required] - public string Name { get; set; } = ""; + [Required(ErrorMessage = "'name' is required for elements under 'quality_profiles'")] + public string Name { get; init; } = ""; - public int? Score { get; set; } + public int? Score { get; init; } } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] diff --git a/src/Trash/Trash.csproj b/src/Trash/Trash.csproj index c0c4d331..9b8d7071 100644 --- a/src/Trash/Trash.csproj +++ b/src/Trash/Trash.csproj @@ -12,8 +12,9 @@ - + + diff --git a/wiki/Configuration-Examples.md b/wiki/Configuration-Examples.md index cbc1fe89..16c9cfcf 100644 --- a/wiki/Configuration-Examples.md +++ b/wiki/Configuration-Examples.md @@ -6,6 +6,7 @@ Various scenarios supported using flexible configuration structure: - [Synchronize a lot of custom formats for a single quality profile](#synchronize-a-lot-of-custom-formats-for-a-single-quality-profile) - [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) ## Update as much as possible in both Sonarr and Radarr with a single config @@ -119,8 +120,12 @@ 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 -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: ```yml radarr: @@ -150,9 +155,13 @@ radarr: ## Manually assign different scores to multiple custom formats -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. +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." + +Solution: ```yml radarr: @@ -233,3 +242,40 @@ radarr: score: 100 # This score is assigned to all 5 CFs in this profile - name: Ultra-HD # Still uses scores from the guide ``` + +## Resolving ambiguity between custom formats with the same name + +Normally when you want a custom format, you list it by name under the `names` property, like so: + +```yml +radarr: + - base_url: http://localhost:7878 + api_key: 87674e2c316645ed85696a91a3d41988 + + custom_formats: + - names: + - FLAC + - DoVi +``` + +However, especially in the case of DoVi, there are actually two custom formats with this name in the +guide. You'll get a warning from Trash Updater stating that it couldn't pick which one you wanted, +so it was skipped. To fix this, simply use `trash_ids` and refer to it by an ID. IDs are never +duplicated in the guide and also never change, so it's a robust and effective way to identify custom +formats. The downside is that they are less readable than a name, but using comments can help with +that. The example below demonstrates how to do this. + +```yml +radarr: + - base_url: http://localhost:7878 + api_key: 87674e2c316645ed85696a91a3d41988 + + custom_formats: + - names: + - FLAC + - trash_ids: + - 5d96ce331b98e077abb8ceb60553aa16 # DoVi +``` + +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. diff --git a/wiki/Configuration-Reference.md b/wiki/Configuration-Reference.md index 6c2a48ee..8f994699 100644 --- a/wiki/Configuration-Reference.md +++ b/wiki/Configuration-Reference.md @@ -198,11 +198,19 @@ Synchronization]] page. in Radarr **will not be deleted** if you enable this setting. - `custom_formats` (Optional; *Default: No custom formats are synced*)
- A list of one or more sets of custom format names, each with an optional set of quality profiles - names that identify which quality profiles to assign the scores for those custom formats to. The - child properties documented below apply to each element of this list. - - - `names` **(Required)**
+ A list of one or more sets of custom formats (by name and/or trash_id), each with an optional set + of quality profiles names that identify which quality profiles to assign the scores for those + custom formats to. The child properties documented below apply to each element of this list. + + > **Note:** Even though `names` and `trash_ids` below are marked *optional*, at least one of them + > is required. For example, if `names` is empty you must use `trash_ids` and vice versa. + > + > When would you use `names` or `trash_ids`? Rule of thumb: Stick to `names`. It's more user + > friendly than IDs, because you can look at a name and know what custom format it is referring + > to. The IDs are there for certain corner cases (you can read more about those in the relevant + > bullet point below). + + - `names` (Optional; *Default: `trash_ids` is required*)
A list of one or more custom format names to synchronize to Radarr. The names *must* be taken from the JSON itself in the guide, for example: @@ -229,6 +237,41 @@ Synchronization]] page. > only ever synchronize it once. Allowing it to be specified multiple times allows you to > assign it to different profiles with different scores. + - `trash_ids` (Optional; *Default: `names` is required*)
+ A list of one or more Trash IDs of custom formats to synchronize to Radarr. The IDs *must* be + taken from the value of the `"trash_id"` property in the JSON itself. It will look like the + following: + + ```json + { + "trash_id": "496f355514737f7d83bf7aa4d24f8169", + } + ``` + + Normally you should be using `names` to specify which custom formats you want. There are a few + rare cases where you might prefer (or need) to use the ID instead: + + - Sometimes there are custom formats in the guide with the same name, such as "DoVi". In this + case, Trash Updater will issue you a warning instructing you to use the Trash ID instead of + the name to resolve the ambiguity. + - Trash IDs never change. Custom format names can change. Trash Updater keeps an internal cache + of every custom format its seen to reduce the need for your config names to be updated. But + it's not 100% fool proof. Using the ID could mean less config maintenance for you in the long + run at the expense of readability. + + Most of the rules and semantics are identical to the `names` property, which is documented + above. Just apply that logic to the ID instead of the name. + + Lastly, as a tip, to ease the readability concerns of using IDs instead of names, leave a + comment beside the Trash ID in your configuration so it can be easily identified later. For + example: + + ```yml + trash_ids: + - 5d96ce331b98e077abb8ceb60553aa16 # dovi + - a570d4a0e56a2874b64e5bfa55202a1b # flac + ``` + - `quality_profiles` (Optional; *Default: No quality profiles are changed*)
One or more quality profiles to update with the scores from the custom formats listed above. Scores are taken from the guide by default, with an option to override the score for all of