fix: Fix false-positive duplicate score warnings

When doing a `sync --preview`, new custom formats are not created and
thus they never get an ID greater than `0`. Because of this, a
dictionary that tracks duplicates based on ID would result in warnings
about duplicate scores that made no sense.

We now index by Trash ID instead of Format ID, which is more accurate.
pull/201/head
Robert Dailey 11 months ago
parent 018d5f0157
commit fe7773ea07

@ -34,6 +34,11 @@ changes you may need to make.
- **BREAKING**: Removed `reset_unmatched_scores` support under quality profile score section. - **BREAKING**: Removed `reset_unmatched_scores` support under quality profile score section.
- **BREAKING**: Migration steps that dealt with the old `trash.yml` have been removed. - **BREAKING**: Migration steps that dealt with the old `trash.yml` have been removed.
### Fixed
- False-positive duplicate score warnings no longer occur when doing `sync --preview` for the first
time.
## [4.4.1] - 2023-04-08 ## [4.4.1] - 2023-04-08
### Fixed ### Fixed

@ -21,4 +21,9 @@ public class ProcessedCustomFormatCache : IPipelineCache
{ {
return _customFormats.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId)); return _customFormats.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId));
} }
public CustomFormatData? LookupByServiceId(int id)
{
return _customFormats.FirstOrDefault(x => x.Id == id);
}
} }

@ -1,4 +1,3 @@
using System.Collections.ObjectModel;
using JetBrains.Annotations; using JetBrains.Annotations;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@ -14,7 +13,7 @@ public record QualityProfileDto
public int MinFormatScore { get; init; } public int MinFormatScore { get; init; }
public int Cutoff { get; init; } public int Cutoff { get; init; }
public int CutoffFormatScore { get; init; } public int CutoffFormatScore { get; init; }
public Collection<ProfileFormatItemDto> FormatItems { get; } = new(); public IReadOnlyCollection<ProfileFormatItemDto> FormatItems { get; init; } = Array.Empty<ProfileFormatItemDto>();
[JsonExtensionData] [JsonExtensionData]
public JObject? ExtraJson { get; init; } public JObject? ExtraJson { get; init; }
@ -25,7 +24,7 @@ public record ProfileFormatItemDto
{ {
public int Format { get; init; } public int Format { get; init; }
public string Name { get; init; } = ""; public string Name { get; init; } = "";
public int Score { get; set; } public int Score { get; init; }
[JsonExtensionData] [JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new(); public Dictionary<string, object> ExtraJson { get; init; } = new();

@ -16,7 +16,12 @@ public class QualityProfileApiPersistencePhase
public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions) public async Task Execute(IServiceConfiguration config, QualityProfileTransactionData transactions)
{ {
foreach (var profile in transactions.UpdatedProfiles.Select(x => x.UpdatedProfile)) var profilesToUpdate = transactions.UpdatedProfiles.Select(x => x.UpdatedProfile with
{
FormatItems = x.UpdatedScores.Select(y => y.Dto with {Score = y.NewScore}).ToList()
});
foreach (var profile in profilesToUpdate)
{ {
await _api.UpdateQualityProfile(config, profile); await _api.UpdateQualityProfile(config, profile);
} }
@ -36,10 +41,10 @@ public class QualityProfileApiPersistencePhase
{ {
_log.Debug("> Scores updated for quality profile: {ProfileName}", profileName); _log.Debug("> Scores updated for quality profile: {ProfileName}", profileName);
foreach (var (customFormatName, oldScore, newScore, reason) in scores) foreach (var (dto, newScore, reason) in scores)
{ {
_log.Debug(" - {Format}: {OldScore} -> {NewScore} ({Reason})", _log.Debug(" - {Format}: {OldScore} -> {NewScore} ({Reason})",
customFormatName, oldScore, newScore, reason); dto.Name, dto.Score, newScore, reason);
} }
} }

@ -5,9 +5,11 @@ using Recyclarr.TrashLib.Models;
namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record ProcessedQualityProfileScore(string TrashId, string CfName, int FormatId, int Score);
public record ProcessedQualityProfileData(QualityProfileConfig Profile) public record ProcessedQualityProfileData(QualityProfileConfig Profile)
{ {
public Dictionary<int, int> CfScores { get; init; } = new(); public IList<ProcessedQualityProfileScore> CfScores { get; init; } = new List<ProcessedQualityProfileScore>();
} }
public class QualityProfileConfigPhase public class QualityProfileConfigPhase
@ -60,7 +62,7 @@ public class QualityProfileConfigPhase
} }
private void AddCustomFormatScoreData( private void AddCustomFormatScoreData(
IDictionary<int, int> existingScoreData, ICollection<ProcessedQualityProfileScore> existingScoreData,
QualityProfileScoreConfig profile, QualityProfileScoreConfig profile,
CustomFormatData cf) CustomFormatData cf)
{ {
@ -71,9 +73,10 @@ public class QualityProfileConfigPhase
return; return;
} }
if (existingScoreData.TryGetValue(cf.Id, out var existingScore)) var existingScore = existingScoreData.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(cf.TrashId));
if (existingScore is not null)
{ {
if (existingScore != scoreToUse) if (existingScore.Score != scoreToUse)
{ {
_log.Warning( _log.Warning(
"Custom format {Name} ({TrashId}) is duplicated in quality profile {ProfileName} with a score " + "Custom format {Name} ({TrashId}) is duplicated in quality profile {ProfileName} with a score " +
@ -88,6 +91,6 @@ public class QualityProfileConfigPhase
return; return;
} }
existingScoreData.Add(cf.Id, scoreToUse.Value); existingScoreData.Add(new ProcessedQualityProfileScore(cf.TrashId, cf.Name, cf.Id, scoreToUse.Value));
} }
} }

@ -26,11 +26,11 @@ public class QualityProfilePreviewPhase
.AddColumn("[bold]New[/]") .AddColumn("[bold]New[/]")
.AddColumn("[bold]Reason[/]"); .AddColumn("[bold]Reason[/]");
foreach (var updatedScore in updatedScores) foreach (var updatedScore in updatedScores.Where(x => x.Reason != FormatScoreUpdateReason.NoChange))
{ {
table.AddRow( table.AddRow(
updatedScore.CustomFormatName, updatedScore.Dto.Name,
updatedScore.OldScore.ToString(), updatedScore.Dto.Score.ToString(),
updatedScore.NewScore.ToString(), updatedScore.NewScore.ToString(),
updatedScore.Reason.ToString()); updatedScore.Reason.ToString());
} }

@ -6,7 +6,7 @@ namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public record UpdatedQualityProfile(QualityProfileDto UpdatedProfile) public record UpdatedQualityProfile(QualityProfileDto UpdatedProfile)
{ {
public Collection<UpdatedFormatScore> UpdatedScores { get; } = new(); public required IReadOnlyCollection<UpdatedFormatScore> UpdatedScores { get; init; }
} }
public record QualityProfileTransactionData public record QualityProfileTransactionData
@ -67,33 +67,33 @@ public class QualityProfileTransactionPhase
ProcessedQualityProfileData profileData, ProcessedQualityProfileData profileData,
QualityProfileDto profileDto) QualityProfileDto profileDto)
{ {
var updatedProfile = new UpdatedQualityProfile(profileDto); var scoreMap = profileData.CfScores
.FullJoin(profileDto.FormatItems,
void UpdateScore(ProfileFormatItemDto item, int newScore, FormatScoreUpdateReason reason) x => x.FormatId,
{ x => x.Format,
if (item.Score == newScore) l => new UpdatedFormatScore
{ {
return; Dto = new ProfileFormatItemDto {Format = l.FormatId, Name = l.CfName},
} NewScore = l.Score,
Reason = FormatScoreUpdateReason.New
updatedProfile.UpdatedScores.Add(new UpdatedFormatScore(item.Name, item.Score, newScore, reason)); },
item.Score = newScore; r => new UpdatedFormatScore
} {
Dto = r,
var scoreMap = profileData.CfScores; NewScore = 0,
Reason = FormatScoreUpdateReason.Reset
foreach (var formatItem in profileDto.FormatItems) },
{ (l, r) => new UpdatedFormatScore
if (scoreMap.TryGetValue(formatItem.Format, out var existingScore)) {
{ Dto = r,
UpdateScore(formatItem, existingScore, FormatScoreUpdateReason.Updated); NewScore = l.Score,
} Reason = FormatScoreUpdateReason.Updated
else if (profileData.Profile is {ResetUnmatchedScores: true}) })
{ .Select(x => x.Dto.Score == x.NewScore ? x with {Reason = FormatScoreUpdateReason.NoChange} : x)
UpdateScore(formatItem, 0, FormatScoreUpdateReason.Reset); .ToList();
}
} return scoreMap.Any(x => x.Reason != FormatScoreUpdateReason.NoChange)
? new UpdatedQualityProfile(profileDto) {UpdatedScores = scoreMap}
return updatedProfile.UpdatedScores.Any() ? updatedProfile : null; : null;
} }
} }

@ -1,13 +1,42 @@
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
namespace Recyclarr.Cli.Pipelines.QualityProfile; namespace Recyclarr.Cli.Pipelines.QualityProfile;
public enum FormatScoreUpdateReason public enum FormatScoreUpdateReason
{ {
/// <summary>
/// A score who's value did not change.
/// </summary>
NoChange,
/// <summary>
/// A score that is changed.
/// </summary>
Updated, Updated,
Reset
/// <summary>
/// Scores were reset to a 0 value because `reset_unmatched_scores` was set to `true`.
/// </summary>
Reset,
/// <summary>
/// New custom format scores (format items) shouldn't exist normally. They do exist during
/// `--preview` runs since new custom formats that aren't synced yet won't be available when
/// processing quality profiles.
/// </summary>
New
} }
public record UpdatedFormatScore( public record UpdatedFormatScore
string CustomFormatName, {
int OldScore, public required ProfileFormatItemDto Dto { get; init; }
int NewScore, public required int NewScore { get; init; }
FormatScoreUpdateReason Reason); public required FormatScoreUpdateReason Reason { get; init; }
public void Deconstruct(out ProfileFormatItemDto dto, out int newScore, out FormatScoreUpdateReason reason)
{
dto = Dto;
newScore = NewScore;
reason = Reason;
}
}

@ -1,3 +1,5 @@
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualityProfile.Api;
using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases; using Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Services;
@ -7,7 +9,7 @@ public static class NewQp
{ {
public static ProcessedQualityProfileData Processed( public static ProcessedQualityProfileData Processed(
string profileName, string profileName,
params (int FormatId, int Score)[] scores) params (string TrashId, int FormatId, int Score)[] scores)
{ {
return Processed(profileName, null, scores); return Processed(profileName, null, scores);
} }
@ -15,14 +17,39 @@ public static class NewQp
public static ProcessedQualityProfileData Processed( public static ProcessedQualityProfileData Processed(
string profileName, string profileName,
bool? resetUnmatchedScores, bool? resetUnmatchedScores,
params (int FormatId, int Score)[] scores) params (string TrashId, int FormatId, int Score)[] scores)
{
return Processed(profileName, resetUnmatchedScores,
scores.Select(x => ("", x.TrashId, x.FormatId, x.Score)).ToArray());
}
public static ProcessedQualityProfileData Processed(
string profileName,
bool? resetUnmatchedScores,
params (string CfName, string TrashId, int FormatId, int Score)[] scores)
{ {
return new ProcessedQualityProfileData(new QualityProfileConfig return new ProcessedQualityProfileData(new QualityProfileConfig
{ {
Name = profileName, ResetUnmatchedScores = resetUnmatchedScores Name = profileName, ResetUnmatchedScores = resetUnmatchedScores
}) })
{ {
CfScores = scores.ToDictionary(x => x.FormatId, x => x.Score) CfScores = scores
.Select(x => new ProcessedQualityProfileScore(x.TrashId, x.CfName, x.FormatId, x.Score))
.ToList()
};
}
public static UpdatedFormatScore UpdatedScore(
string name,
int oldScore,
int newScore,
FormatScoreUpdateReason reason)
{
return new UpdatedFormatScore
{
Dto = new ProfileFormatItemDto {Name = name, Score = oldScore},
NewScore = newScore,
Reason = reason
}; };
} }
} }

@ -47,7 +47,7 @@ public class QualityProfileConfigPhaseTest
result.Should().BeEquivalentTo(new[] result.Should().BeEquivalentTo(new[]
{ {
NewQp.Processed("test_profile", (1, 100), (2, 100)) NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 100))
}); });
} }
@ -78,7 +78,7 @@ public class QualityProfileConfigPhaseTest
result.Should().BeEquivalentTo(new[] result.Should().BeEquivalentTo(new[]
{ {
NewQp.Processed("test_profile", (1, 100), (2, 200)) NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 200))
}); });
} }
@ -163,8 +163,8 @@ public class QualityProfileConfigPhaseTest
result.Should().BeEquivalentTo(new[] result.Should().BeEquivalentTo(new[]
{ {
NewQp.Processed("test_profile1", (1, 100)), NewQp.Processed("test_profile1", ("id1", 1, 100)),
NewQp.Processed("test_profile2", (1, 200)) NewQp.Processed("test_profile2", ("id1", 1, 200))
}); });
} }
} }

@ -16,7 +16,7 @@ public class QualityProfileTransactionPhaseTest
{ {
var guideData = new[] var guideData = new[]
{ {
NewQp.Processed("invalid_profile_name", (1, 100)) NewQp.Processed("invalid_profile_name", ("id1", 1, 100))
}; };
var serviceData = new[] var serviceData = new[]
@ -41,7 +41,7 @@ public class QualityProfileTransactionPhaseTest
{ {
var guideData = new[] var guideData = new[]
{ {
NewQp.Processed("profile1", (1, 100), (2, 500)) NewQp.Processed("profile1", ("id1", 1, 100), ("id2", 2, 500))
}; };
var serviceData = new[] var serviceData = new[]
@ -49,7 +49,7 @@ public class QualityProfileTransactionPhaseTest
new QualityProfileDto new QualityProfileDto
{ {
Name = "profile1", Name = "profile1",
FormatItems = FormatItems = new[]
{ {
new ProfileFormatItemDto new ProfileFormatItemDto
{ {
@ -69,38 +69,13 @@ public class QualityProfileTransactionPhaseTest
var result = sut.Execute(guideData, serviceData); var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData result.UpdatedProfiles.Should()
{ .ContainSingle().Which.UpdatedScores.Should()
UpdatedProfiles = .BeEquivalentTo(new[]
{ {
new UpdatedQualityProfile(new QualityProfileDto NewQp.UpdatedScore("quality1", 200, 100, FormatScoreUpdateReason.Updated),
{ NewQp.UpdatedScore("quality2", 300, 500, FormatScoreUpdateReason.Updated)
Name = "profile1", }, o => o.Excluding(x => x.Dto.Format));
FormatItems =
{
new ProfileFormatItemDto
{
Name = "quality1",
Format = 1,
Score = 100
},
new ProfileFormatItemDto
{
Name = "quality2",
Format = 2,
Score = 500
}
}
})
{
UpdatedScores =
{
new UpdatedFormatScore("quality1", 200, 100, FormatScoreUpdateReason.Updated),
new UpdatedFormatScore("quality2", 300, 500, FormatScoreUpdateReason.Updated)
}
}
}
});
} }
[Test, AutoMockData] [Test, AutoMockData]
@ -114,7 +89,7 @@ public class QualityProfileTransactionPhaseTest
new QualityProfileDto new QualityProfileDto
{ {
Name = "profile1", Name = "profile1",
FormatItems = FormatItems = new[]
{ {
new ProfileFormatItemDto new ProfileFormatItemDto
{ {
@ -145,7 +120,7 @@ public class QualityProfileTransactionPhaseTest
// Profile name must match but the format IDs for each quality should not // Profile name must match but the format IDs for each quality should not
var guideData = new[] var guideData = new[]
{ {
NewQp.Processed("profile1", (1, 200), (2, 300)) NewQp.Processed("profile1", ("id1", 1, 200), ("id2", 2, 300))
}; };
var serviceData = new[] var serviceData = new[]
@ -153,7 +128,7 @@ public class QualityProfileTransactionPhaseTest
new QualityProfileDto new QualityProfileDto
{ {
Name = "profile1", Name = "profile1",
FormatItems = FormatItems = new[]
{ {
new ProfileFormatItemDto new ProfileFormatItemDto
{ {
@ -182,7 +157,7 @@ public class QualityProfileTransactionPhaseTest
{ {
var guideData = new[] var guideData = new[]
{ {
NewQp.Processed("profile1", true, (3, 100), (4, 500)) NewQp.Processed("profile1", true, ("quality3", "id3", 3, 100), ("quality4", "id4", 4, 500))
}; };
var serviceData = new[] var serviceData = new[]
@ -190,7 +165,7 @@ public class QualityProfileTransactionPhaseTest
new QualityProfileDto new QualityProfileDto
{ {
Name = "profile1", Name = "profile1",
FormatItems = FormatItems = new[]
{ {
new ProfileFormatItemDto new ProfileFormatItemDto
{ {
@ -210,37 +185,14 @@ public class QualityProfileTransactionPhaseTest
var result = sut.Execute(guideData, serviceData); var result = sut.Execute(guideData, serviceData);
result.Should().BeEquivalentTo(new QualityProfileTransactionData result.UpdatedProfiles.Should()
{ .ContainSingle().Which.UpdatedScores.Should()
UpdatedProfiles = .BeEquivalentTo(new[]
{ {
new UpdatedQualityProfile(new QualityProfileDto NewQp.UpdatedScore("quality1", 200, 0, FormatScoreUpdateReason.Reset),
{ NewQp.UpdatedScore("quality2", 300, 0, FormatScoreUpdateReason.Reset),
Name = "profile1", NewQp.UpdatedScore("quality3", 0, 100, FormatScoreUpdateReason.New),
FormatItems = NewQp.UpdatedScore("quality4", 0, 500, FormatScoreUpdateReason.New)
{ }, o => o.Excluding(x => x.Dto.Format));
new ProfileFormatItemDto
{
Name = "quality1",
Format = 1,
Score = 0
},
new ProfileFormatItemDto
{
Name = "quality2",
Format = 2,
Score = 0
}
}
})
{
UpdatedScores =
{
new UpdatedFormatScore("quality1", 200, 0, FormatScoreUpdateReason.Reset),
new UpdatedFormatScore("quality2", 300, 0, FormatScoreUpdateReason.Reset)
}
}
}
});
} }
} }

Loading…
Cancel
Save