feat(radarr): new setting to reset unmapped scores

A new setting under `quality_profiles` of the custom format listing
named `reset_unmatched_scores` that allows the user to specify if
unmapped scores (those CFs not specified in config) should be reset to 0
during quality profile updates.

Fixes #10.
recyclarr
Robert Dailey 3 years ago
parent df203e3b46
commit fe9873fb05

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

@ -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()};
}
}

@ -0,0 +1,5 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
</Project>

@ -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<string, List<QualityProfileCustomFormatScoreEntry>>
.BeEquivalentTo(new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
"profile1", new List<QualityProfileCustomFormatScoreEntry>
{
new(expectedProcessedCustomFormatData[0], 500),
new(expectedProcessedCustomFormatData[1], 480)
}
"profile1", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[0], 500),
new FormatMappingEntry(expectedProcessedCustomFormatData[1], 480))
},
{
"profile2", new List<QualityProfileCustomFormatScoreEntry>
{
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<QualityProfileCustomFormatScoreEntry>
{
new(expectedProcessedCustomFormatData[2], 5678)
}
"profile4", CfTestUtils.NewMapping(
new FormatMappingEntry(expectedProcessedCustomFormatData[2], 5678))
}
}, op => op.Using(new JsonEquivalencyStep()));
}, op => op
.Using(new JsonEquivalencyStep())
.ComparingByMembers<FormatMappingEntry>());
}
}
}

@ -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<QualityProfileCustomFormatScoreEntry>
{
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<QualityProfileCustomFormatScoreEntry>
{
new(testConfigData[0].CustomFormats[0], 100)
};
var expectedScoreEntries =
CfTestUtils.NewMapping(new FormatMappingEntry(testConfigData[0].CustomFormats[0], 100));
processor.ProfileScores.Should().BeEquivalentTo(
new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"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<QualityProfileCustomFormatScoreEntry>
{
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();
}

@ -29,7 +29,7 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors
var guideCfs = Array.Empty<ProcessedCustomFormatData>();
var deletedCfsInCache = new Collection<TrashIdMapping>();
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
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<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
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<ProcessedCustomFormatData>();
var deletedCfsInCache = Array.Empty<TrashIdMapping>();
var profileScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>();
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);

@ -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<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
"profile1", new List<QualityProfileCustomFormatScoreEntry>
{
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<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{"wrong_profile_name", new List<QualityProfileCustomFormatScoreEntry>()}
{"wrong_profile_name", CfTestUtils.NewMapping()}
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
processor.InvalidProfileNames.Should().BeEquivalentTo("wrong_profile_name");
processor.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<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
"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<UpdatedFormatScore>
{
new("cf1", 0, FormatScoreUpdateReason.Reset),
new("cf2", 100, FormatScoreUpdateReason.Updated),
new("cf3", 0, FormatScoreUpdateReason.Reset)
});
api.Received().UpdateQualityProfile(
Verify.That<JObject>(j => j["formatItems"].Children().Should().HaveCount(3)),
Arg.Any<int>());
}
[Test]
public void Scores_are_set_in_quality_profile()
{
@ -132,27 +185,25 @@ namespace Trash.Tests.Radarr.CustomFormat.Processors.PersistenceSteps
var api = Substitute.For<IQualityProfileService>();
api.GetQualityProfiles().Returns(JsonConvert.DeserializeObject<List<JObject>>(radarrQualityProfileData));
var cfScores = new Dictionary<string, List<QualityProfileCustomFormatScoreEntry>>
var cfScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>
{
{
"profile1", new List<QualityProfileCustomFormatScoreEntry>
{
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<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should().ContainKey("profile1").WhichValue.Should().BeEquivalentTo(
cfScores.Values.First()[0],
cfScores.Values.First()[2]);
processor.UpdatedScores.Should()
.ContainKey("profile1").WhichValue.Should()
.BeEquivalentTo(new List<UpdatedFormatScore>
{
new("3D", 100, FormatScoreUpdateReason.Updated),
new("asdf2", 102, FormatScoreUpdateReason.Updated)
});
}
}
}

@ -5,6 +5,7 @@
<ItemGroup>
<ProjectReference Include="..\TestLibrary\TestLibrary.csproj" />
<ProjectReference Include="..\Trash.TestLibrary\Trash.TestLibrary.csproj" />
<ProjectReference Include="..\Trash\Trash.csproj" />
</ItemGroup>
</Project>

@ -16,16 +16,9 @@ namespace Trash.Extensions
return val;
}
public static TValue GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> dict, TKey key)
where TValue : struct
public static TValue? GetOrDefault<TKey, TValue>(this IDictionary<TKey, TValue> 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;
}
}
}

@ -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);
}
}

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

@ -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<FormatMappingEntry> Mapping { get; init; } = new();
}
}

@ -0,0 +1,13 @@
namespace Trash.Radarr.CustomFormat.Models
{
public enum FormatScoreUpdateReason
{
Updated,
Reset
}
public record UpdatedFormatScore(
string CustomFormatName,
int Score,
FormatScoreUpdateReason Reason);
}

@ -43,7 +43,7 @@ namespace Trash.Radarr.CustomFormat.Processors
public IReadOnlyCollection<ProcessedConfigData> ConfigData
=> _steps.Config.ConfigData;
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores
public IDictionary<string, QualityProfileCustomFormatScoreMapping> ProfileScores
=> _steps.QualityProfile.ProfileScores;
public IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore

@ -5,7 +5,7 @@ namespace Trash.Radarr.CustomFormat.Processors.GuideSteps
{
public interface IQualityProfileStep
{
Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
Dictionary<string, QualityProfileCustomFormatScoreMapping> ProfileScores { get; }
List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
void Process(IEnumerable<ProcessedConfigData> configData);
}

@ -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<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; } = new();
public Dictionary<string, QualityProfileCustomFormatScoreMapping> ProfileScores { get; } = new();
public List<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; } = new();
public void Process(IEnumerable<ProcessedConfigData> 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));
}
}
}

@ -10,7 +10,7 @@ namespace Trash.Radarr.CustomFormat.Processors
IReadOnlyCollection<ProcessedCustomFormatData> ProcessedCustomFormats { get; }
IReadOnlyCollection<string> CustomFormatsNotInGuide { get; }
IReadOnlyCollection<ProcessedConfigData> ConfigData { get; }
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> ProfileScores { get; }
IDictionary<string, QualityProfileCustomFormatScoreMapping> ProfileScores { get; }
IReadOnlyCollection<(string name, string trashId, string profileName)> CustomFormatsWithoutScore { get; }
IReadOnlyCollection<TrashIdMapping> DeletedCustomFormatsInCache { get; }
List<(string, string)> CustomFormatsWithOutdatedNames { get; }

@ -8,13 +8,13 @@ namespace Trash.Radarr.CustomFormat.Processors
{
public interface IPersistenceProcessor
{
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
IDictionary<string, List<UpdatedFormatScore>> UpdatedScores { get; }
IReadOnlyCollection<string> InvalidProfileNames { get; }
CustomFormatTransactionData Transactions { get; }
Task PersistCustomFormats(IReadOnlyCollection<ProcessedCustomFormatData> guideCfs,
IEnumerable<TrashIdMapping> deletedCfsInCache,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> profileScores);
IDictionary<string, QualityProfileCustomFormatScoreMapping> profileScores);
void Reset();
}

@ -40,7 +40,7 @@ namespace Trash.Radarr.CustomFormat.Processors
public CustomFormatTransactionData Transactions
=> _steps.JsonTransactionStep.Transactions;
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores
public IDictionary<string, List<UpdatedFormatScore>> UpdatedScores
=> _steps.ProfileQualityProfileApiPersister.UpdatedScores;
public IReadOnlyCollection<string> InvalidProfileNames
@ -53,7 +53,7 @@ namespace Trash.Radarr.CustomFormat.Processors
public async Task PersistCustomFormats(IReadOnlyCollection<ProcessedCustomFormatData> guideCfs,
IEnumerable<TrashIdMapping> deletedCfsInCache,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> profileScores)
IDictionary<string, QualityProfileCustomFormatScoreMapping> profileScores)
{
var radarrCfs = await _customFormatService.GetCustomFormats();

@ -7,10 +7,10 @@ namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
{
public interface IQualityProfileApiPersistenceStep
{
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores { get; }
IDictionary<string, List<UpdatedFormatScore>> UpdatedScores { get; }
IReadOnlyCollection<string> InvalidProfileNames { get; }
Task Process(IQualityProfileService api,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> cfScores);
IDictionary<string, QualityProfileCustomFormatScoreMapping> cfScores);
}
}

@ -12,45 +12,60 @@ namespace Trash.Radarr.CustomFormat.Processors.PersistenceSteps
public class QualityProfileApiPersistenceStep : IQualityProfileApiPersistenceStep
{
private readonly List<string> _invalidProfileNames = new();
private readonly Dictionary<string, List<QualityProfileCustomFormatScoreEntry>> _updatedScores = new();
private readonly Dictionary<string, List<UpdatedFormatScore>> _updatedScores = new();
public IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> UpdatedScores => _updatedScores;
public IDictionary<string, List<UpdatedFormatScore>> UpdatedScores => _updatedScores;
public IReadOnlyCollection<string> InvalidProfileNames => _invalidProfileNames;
public async Task Process(IQualityProfileService api,
IDictionary<string, List<QualityProfileCustomFormatScoreEntry>> cfScores)
IDictionary<string, QualityProfileCustomFormatScoreMapping> 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<JObject>()).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<JObject>()).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<JObject> 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);
}
}
}

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

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

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

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

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

Loading…
Cancel
Save