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.
recyclarr
Robert Dailey 3 years ago
parent 08541961bd
commit 46675e38c5

@ -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

@ -6,7 +6,6 @@
<PackageReference Update="FluentAssertions" Version="5.*" />
<PackageReference Update="GitHubActionsTestLogger" Version="1.*" />
<PackageReference Update="Microsoft.NET.Test.Sdk" Version="16.*" />
<PackageReference Update="morelinq" Version="3.*" />
<PackageReference Update="NSubstitute.Analyzers.CSharp" Version="1.*" />
<PackageReference Update="NSubstitute" Version="4.*" />
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
@ -24,6 +23,7 @@
<PackageReference Update="Flurl" Version="3.*" />
<PackageReference Update="JetBrains.Annotations" Version="*"/>
<PackageReference Update="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.*"/>
<PackageReference Update="morelinq" Version="3.*" />
<PackageReference Update="Nerdbank.GitVersioning" Version="3.*"/>
<PackageReference Update="Serilog.Sinks.Console" Version="3.*" />
<PackageReference Update="Serilog.Sinks.File" Version="4.*" />

@ -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
{

@ -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<ProcessedCustomFormatData>
{
@ -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<string> {"name1", "name3"},
QualityProfiles = new List<QualityProfileConfig>
{
new() {Name = "profile1", Score = 50}
}
Names = new List<string> {"name1"}
}
};
@ -45,8 +44,8 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps
{
new()
{
CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles
CustomFormats = new List<ProcessedCustomFormatData>
{testProcessedCfs[1]}
}
}, op => op
.Using<JToken>(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<ProcessedCustomFormatData>
{
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<ProcessedCustomFormatData>
{testProcessedCfs[1]}
{
new("name1", "", new JObject())
}
}
}, op => op
.Using<JToken>(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<ProcessedCustomFormatData>
{
@ -105,14 +100,16 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.GuideSteps
{
new()
{
Names = new List<string> {"name1"}
Names = new List<string> {"name1", "name3"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.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()
@ -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<ProcessedCustomFormatData>
{
new("name1", "", new JObject()),
new("name2", "", new JObject())
new("name1", "id1", new JObject())
};
var testConfig = new CustomFormatConfig[]
{
new()
{
Names = new List<string> {"name1", "name3"}
Names = new List<string> {"name1"},
TrashIds = new List<string> {"id1"}
}
};
var processor = new ConfigStep();
processor.Process(testProcessedCfs, testConfig);
processor.CustomFormatsNotInGuide.Should().BeEquivalentTo(new List<string> {"name3"}, 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 = new List<ProcessedCustomFormatData>
CustomFormats = new List<ProcessedCustomFormatData> {testProcessedCfs[0]}
}
});
}
[Test]
public void Duplicate_config_names_are_ignored()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
new("name1", "id1", new JObject())
};
var testConfig = new CustomFormatConfig[]
{
new() {Names = new List<string> {"name1", "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[0]}
}
});
}
[Test]
public void Find_custom_formats_by_name_and_trash_id()
{
var testProcessedCfs = new List<ProcessedCustomFormatData>
{
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<string> {"name1", "name3"},
TrashIds = new List<string> {"id1", "id4"},
QualityProfiles = new List<QualityProfileConfig>
{
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<ProcessedConfigData>
{
new()
{
CustomFormats = testProcessedCfs,
QualityProfiles = testConfig[0].QualityProfiles
}
}, op => op
.Using<JToken>(jctx => jctx.Subject.Should().BeEquivalentTo(jctx.Expectation))
.WhenTypeIs<JToken>());

@ -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<CustomFormatData>
{
new() {Json = @"{'name': 'name1', 'trash_id': 'id1'}"},
new() {Json = @"{'name': 'name1', 'trash_id': 'id2'}"}
};
var testConfig = new List<CustomFormatConfig>
{
new() {Names = new List<string> {"name1", "NAME1"}}
new() {Names = new List<string> {"name1"}}
};
var processor = new CustomFormatStep();
processor.Process(ctx.TestGuideData, testConfig, new CustomFormatCache());
processor.Process(guideData, testConfig, null);
processor.DuplicatedCustomFormats.Should().BeEmpty();
//Dictionary<string, List<ProcessedCustomFormatData>>
processor.DuplicatedCustomFormats.Should().ContainKey("name1")
.WhichValue.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
{
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<ProcessedCustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"doesnt_exist"}}
new() {Names = new List<string> {"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<ProcessedCustomFormatData>
{
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<CustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"name1"}}
new() {TrashIds = new List<string> {"id2"}}
};
var processor = new CustomFormatStep();
processor.Process(guideData, testConfig, null);
//Dictionary<string, List<ProcessedCustomFormatData>>
processor.DuplicatedCustomFormats.Should().ContainKey("name1")
.WhichValue.Should().BeEquivalentTo(new List<ProcessedCustomFormatData>
processor.DuplicatedCustomFormats.Should().BeEmpty();
processor.CustomFormatsWithOutdatedNames.Should().BeEmpty();
processor.DeletedCustomFormatsInCache.Should().BeEmpty();
processor.ProcessedCustomFormats.Should()
.BeEquivalentTo(new List<ProcessedCustomFormatData>
{
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<CustomFormatConfig>
{
new() {Names = new List<string> {"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();

@ -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<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), 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<RadarrConfiguration>(
Substitute.For<IConfigurationProvider>(),
Substitute.For<IFileSystem>(), new DefaultObjectFactory());
Action act = () => configLoader.LoadFromStream(new StringReader(testYaml), "radarr");
act.Should().Throw<YamlException>();
act.Should().Throw<ConfigurationException>()
.WithMessage("*must contain at least one element in either 'names' or 'trash_ids'.");
}
[Test]

@ -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)
{

@ -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<ProcessedCustomFormatData> processedCfs,
IEnumerable<CustomFormatConfig> 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<ProcessedCustomFormatData>();
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<ProcessedCustomFormatData> processedCfs, string name)
{
return processedCfs.FirstOrDefault(
cf => cf.CacheEntry?.CustomFormatName.EqualsIgnoreCase(name) ?? false) ??
processedCfs.FirstOrDefault(
cf => cf.Name.EqualsIgnoreCase(name));
}
}
}

@ -18,16 +18,29 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
public Dictionary<string, List<ProcessedCustomFormatData>> DuplicatedCustomFormats { get; private set; } =
new();
public void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> config,
CustomFormatCache? cache)
public void Process(IEnumerable<CustomFormatData> customFormatGuideData,
IReadOnlyCollection<CustomFormatConfig> 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.

@ -12,7 +12,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
List<(string, string)> CustomFormatsWithOutdatedNames { get; }
Dictionary<string, List<ProcessedCustomFormatData>> DuplicatedCustomFormats { get; }
void Process(IEnumerable<CustomFormatData> customFormatGuideData, IEnumerable<CustomFormatConfig> config,
CustomFormatCache? cache);
void Process(IEnumerable<CustomFormatData> customFormatGuideData,
IReadOnlyCollection<CustomFormatConfig> config, CustomFormatCache? cache);
}
}

@ -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<CustomFormatConfig> CustomFormats { get; set; } = new();
public bool DeleteOldCustomFormats { get; set; }
public List<CustomFormatConfig> 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<string> Names { get; set; } = new();
public List<QualityProfileConfig> QualityProfiles { get; set; } = new();
public List<string> Names { get; init; } = new();
public List<string> TrashIds { get; init; } = new();
public List<QualityProfileConfig> 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)]

@ -12,8 +12,9 @@
<PackageReference Include="CliFx" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Flurl" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="morelinq" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog" />
<PackageReference Include="System.Data.HashFunction.FNV" />
<PackageReference Include="System.IO.Abstractions" />

@ -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.

@ -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*)<br>
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)**<br>
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*)<br>
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*)<br>
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*)<br>
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

Loading…
Cancel
Save