fix: Use new quality size limits in Radarr & Sonarr

- Max/Preferred in Sonarr bumped to 1000/995.
- Max/Preferred in Radarr bumped to 2000/1999.
pull/312/head
Robert Dailey 1 month ago
parent 51ebfa21c5
commit 042840b8bc

@ -8,6 +8,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- Quality Definition: Support new quality upper limits for Sonarr (1000) and Radarr (2000). This is
a backward compatible change, so older versions of Sonarr and Radarr will continue to use the
correct upper limits.
## [7.2.1] - 2024-08-03
### Fixed

@ -15,7 +15,7 @@ public class CustomFormatConfigPhase(
IServiceConfiguration config)
: IConfigPipelinePhase<CustomFormatPipelineContext>
{
public Task Execute(CustomFormatPipelineContext context)
public Task Execute(CustomFormatPipelineContext context, CancellationToken ct)
{
// Match custom formats in the YAML config to those in the guide, by Trash ID
//

@ -22,7 +22,7 @@ public class GenericSyncPipeline<TContext>(
return;
}
await phases.ConfigPhase.Execute(context);
await phases.ConfigPhase.Execute(context, ct);
if (phases.LogPhase.LogConfigPhaseAndExitIfNeeded(context))
{
return;

@ -3,5 +3,5 @@ namespace Recyclarr.Cli.Pipelines.Generic;
public interface IConfigPipelinePhase<in TContext>
where TContext : IPipelineContext
{
Task Execute(TContext context);
Task Execute(TContext context, CancellationToken ct);
}

@ -22,7 +22,7 @@ public class MediaNamingConfigPhase(
IServiceConfiguration config)
: IConfigPipelinePhase<MediaNamingPipelineContext>
{
public async Task Execute(MediaNamingPipelineContext context)
public async Task Execute(MediaNamingPipelineContext context, CancellationToken ct)
{
var lookup = new NamingFormatLookup();
var strategy = configPhaseStrategyFactory[config.ServiceType];

@ -10,7 +10,7 @@ namespace Recyclarr.Cli.Pipelines.QualityProfile.PipelinePhases;
public class QualityProfileConfigPhase(ILogger log, ProcessedCustomFormatCache cache, IServiceConfiguration config)
: IConfigPipelinePhase<QualityProfilePipelineContext>
{
public Task Execute(QualityProfilePipelineContext context)
public Task Execute(QualityProfilePipelineContext context, CancellationToken ct)
{
// 1. For each group of CFs that has a quality profile specified
// 2. For each quality profile score config in that CF group

@ -4,16 +4,16 @@ using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
public class QualityItemLimitFactory(IIndex<SupportedServices, IQualityItemLimits> limitFactory)
public class QualityItemLimitFactory(IIndex<SupportedServices, IQualityItemLimitFetcher> limitFactory)
{
public QualityItemWithLimits Create(QualityItem item, SupportedServices serviceType)
public async Task<QualityItemLimits> Create(SupportedServices serviceType, CancellationToken ct)
{
if (!limitFactory.TryGetValue(serviceType, out var limits))
if (!limitFactory.TryGetValue(serviceType, out var limitFetcher))
{
throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType,
"No quality item limits defined for this service type");
}
return new QualityItemWithLimits(item, limits);
return await limitFetcher.GetLimits(ct);
}
}

@ -0,0 +1,25 @@
using Recyclarr.Compatibility.Radarr;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
public class RadarrQualityItemLimitFetcher(IRadarrCapabilityFetcher capabilityFetcher) : IQualityItemLimitFetcher
{
private QualityItemLimits? _cachedLimits;
public async Task<QualityItemLimits> GetLimits(CancellationToken ct)
{
// ReSharper disable once InvertIf
if (_cachedLimits is null)
{
var capabilities = await capabilityFetcher.GetCapabilities(ct);
_cachedLimits = capabilities switch
{
{QualityDefinitionLimitsIncreased: true} => new QualityItemLimits(2000m, 1999m),
_ => new QualityItemLimits(400m, 399m)
};
}
return _cachedLimits;
}
}

@ -1,9 +0,0 @@
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
public class RadarrQualityItemLimits : IQualityItemLimits
{
public decimal MaxLimit => 400m;
public decimal PreferredLimit => 399m;
}

@ -0,0 +1,25 @@
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
public class SonarrQualityItemLimitFetcher(ISonarrCapabilityFetcher capabilityFetcher) : IQualityItemLimitFetcher
{
private QualityItemLimits? _cachedLimits;
public async Task<QualityItemLimits> GetLimits(CancellationToken ct)
{
// ReSharper disable once InvertIf
if (_cachedLimits is null)
{
var capabilities = await capabilityFetcher.GetCapabilities(ct);
_cachedLimits = capabilities switch
{
{QualityDefinitionLimitsIncreased: true} => new QualityItemLimits(1000m, 995m),
_ => new QualityItemLimits(400m, 395m)
};
}
return _cachedLimits;
}
}

@ -1,9 +0,0 @@
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases.Limits;
public class SonarrQualityItemLimits : IQualityItemLimits
{
public decimal MaxLimit => 400m;
public decimal PreferredLimit => 395m;
}

@ -11,16 +11,16 @@ public class QualitySizeConfigPhase(
ILogger log,
IQualitySizeGuideService guide,
IServiceConfiguration config,
QualityItemLimitFactory itemFactory)
QualityItemLimitFactory limitFactory)
: IConfigPipelinePhase<QualitySizePipelineContext>
{
public Task Execute(QualitySizePipelineContext context)
public async Task Execute(QualitySizePipelineContext context, CancellationToken ct)
{
var configSizeData = config.QualityDefinition;
if (configSizeData is null)
{
log.Debug("{Instance} has no quality definition", config.InstanceName);
return Task.CompletedTask;
return;
}
var guideSizeData = guide.GetQualitySizeData(config.ServiceType)
@ -29,16 +29,17 @@ public class QualitySizeConfigPhase(
if (guideSizeData == null)
{
context.ConfigError = $"The specified quality definition type does not exist: {configSizeData.Type}";
return Task.CompletedTask;
return;
}
var itemLimits = await limitFactory.Create(config.ServiceType, ct);
var sizeDataWithThresholds = guideSizeData.Qualities
.Select(x => itemFactory.Create(x, config.ServiceType))
.Select(x => new QualityItemWithLimits(x, itemLimits))
.ToList();
AdjustPreferredRatio(configSizeData, sizeDataWithThresholds);
context.ConfigOutput = new ProcessedQualitySizeData(configSizeData.Type, sizeDataWithThresholds);
return Task.CompletedTask;
}
private void AdjustPreferredRatio(QualityDefinitionConfig configSizeData, List<QualityItemWithLimits> guideSizeData)

@ -15,8 +15,12 @@ public class QualitySizeAutofacModule : Module
// Setup factory for creation of concrete IQualityItemLimits types
builder.RegisterType<QualityItemLimitFactory>();
builder.RegisterType<RadarrQualityItemLimits>().Keyed<IQualityItemLimits>(SupportedServices.Radarr);
builder.RegisterType<SonarrQualityItemLimits>().Keyed<IQualityItemLimits>(SupportedServices.Sonarr);
builder.RegisterType<RadarrQualityItemLimitFetcher>()
.Keyed<IQualityItemLimitFetcher>(SupportedServices.Radarr)
.InstancePerLifetimeScope();
builder.RegisterType<SonarrQualityItemLimitFetcher>()
.Keyed<IQualityItemLimitFetcher>(SupportedServices.Sonarr)
.InstancePerLifetimeScope();
builder.RegisterTypes(
typeof(QualitySizeConfigPhase),

@ -4,10 +4,7 @@ namespace Recyclarr.Compatibility.Radarr;
//
// May get used one day; keep the parameter around so that calling
// code does not need to be changed later.
public record RadarrCapabilities(Version? Version)
public record RadarrCapabilities(Version Version)
{
public RadarrCapabilities()
: this((Version?) null)
{
}
public bool QualityDefinitionLimitsIncreased => Version >= new Version(5, 9, 0, 9049);
}

@ -3,7 +3,7 @@ namespace Recyclarr.Compatibility.Radarr;
public class RadarrCapabilityFetcher(IServiceInformation info)
: ServiceCapabilityFetcher<RadarrCapabilities>(info), IRadarrCapabilityFetcher
{
protected override RadarrCapabilities BuildCapabilitiesObject(Version? version)
protected override RadarrCapabilities BuildCapabilitiesObject(Version version)
{
return new RadarrCapabilities(version);
}

@ -1,17 +1,8 @@
namespace Recyclarr.Compatibility.Sonarr;
public record SonarrCapabilities
public record SonarrCapabilities(Version Version)
{
public SonarrCapabilities()
{
}
public SonarrCapabilities(Version version)
{
Version = version;
}
public static Version MinimumVersion { get; } = new("4.0.0.0");
public Version Version { get; init; } = new();
public bool QualityDefinitionLimitsIncreased => Version >= new Version(4, 0, 8, 2158);
}

@ -5,9 +5,6 @@ public class SonarrCapabilityFetcher(IServiceInformation info)
{
protected override SonarrCapabilities BuildCapabilitiesObject(Version version)
{
return new SonarrCapabilities
{
Version = version
};
return new SonarrCapabilities(version);
}
}

@ -0,0 +1,8 @@
namespace Recyclarr.TrashGuide.QualitySize;
public record QualityItemLimits(decimal MaxLimit, decimal PreferredLimit);
public interface IQualityItemLimitFetcher
{
Task<QualityItemLimits> GetLimits(CancellationToken ct);
}

@ -1,7 +0,0 @@
namespace Recyclarr.TrashGuide.QualitySize;
public interface IQualityItemLimits
{
decimal MaxLimit { get; }
decimal PreferredLimit { get; }
}

@ -3,18 +3,23 @@ using System.Text;
namespace Recyclarr.TrashGuide.QualitySize;
public class QualityItemWithLimits(QualityItem item, IQualityItemLimits limits)
public class QualityItemWithLimits(QualityItem item, QualityItemLimits limits)
{
public QualityItem Item => item;
public IQualityItemLimits Limits => limits;
public QualityItem Item { get; } = item with
{
Max = Math.Min(item.Max, limits.MaxLimit),
Preferred = Math.Min(item.Preferred, limits.PreferredLimit)
};
public QualityItemLimits Limits => limits;
public decimal MinForApi => item.Min;
public decimal? PreferredForApi => item.Preferred < limits.PreferredLimit ? item.Preferred : null;
public decimal? MaxForApi => item.Max < limits.MaxLimit ? item.Max : null;
public decimal MinForApi => Item.Min;
public decimal? PreferredForApi => Item.Preferred < Limits.PreferredLimit ? Item.Preferred : null;
public decimal? MaxForApi => Item.Max < Limits.MaxLimit ? Item.Max : null;
public string AnnotatedMin => item.Min.ToString(CultureInfo.InvariantCulture);
public string AnnotatedPreferred => AnnotatedValue(item.Preferred, limits.PreferredLimit);
public string AnnotatedMax => AnnotatedValue(item.Max, limits.MaxLimit);
public string AnnotatedMin => Item.Min.ToString(CultureInfo.InvariantCulture);
public string AnnotatedPreferred => AnnotatedValue(Item.Preferred, Limits.PreferredLimit);
public string AnnotatedMax => AnnotatedValue(Item.Max, Limits.MaxLimit);
private static string AnnotatedValue(decimal value, decimal threshold)
{
@ -29,17 +34,17 @@ public class QualityItemWithLimits(QualityItem item, IQualityItemLimits limits)
public bool IsMinDifferent(decimal serviceValue)
{
return serviceValue != item.Min;
return serviceValue != Item.Min;
}
public bool IsPreferredDifferent(decimal? serviceValue)
{
return ValueWithThresholdIsDifferent(serviceValue, item.Preferred, limits.PreferredLimit);
return ValueWithThresholdIsDifferent(serviceValue, Item.Preferred, Limits.PreferredLimit);
}
public bool IsMaxDifferent(decimal? serviceValue)
{
return ValueWithThresholdIsDifferent(serviceValue, item.Max, limits.MaxLimit);
return ValueWithThresholdIsDifferent(serviceValue, Item.Max, Limits.MaxLimit);
}
private static bool ValueWithThresholdIsDifferent(decimal? serviceValue, decimal guideValue, decimal threshold)
@ -54,7 +59,7 @@ public class QualityItemWithLimits(QualityItem item, IQualityItemLimits limits)
public decimal InterpolatedPreferred(decimal ratio)
{
var cappedMax = Math.Min(item.Max, limits.PreferredLimit);
return Math.Round(item.Min + (cappedMax - item.Min) * ratio, 1);
var cappedMax = Math.Min(Item.Max, Limits.PreferredLimit);
return Math.Round(Item.Min + (cappedMax - Item.Min) * ratio, 1);
}
}

@ -38,7 +38,7 @@ public class CustomFormatConfigPhaseTest
var context = new CustomFormatPipelineContext();
var sut = fixture.Create<CustomFormatConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewCf.Data("one", "cf1"),
@ -74,7 +74,7 @@ public class CustomFormatConfigPhaseTest
var context = new CustomFormatPipelineContext();
var sut = fixture.Create<CustomFormatConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEmpty();
}

@ -45,7 +45,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 100))
@ -78,7 +78,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewQp.Processed("test_profile", ("id1", 1, 100), ("id2", 2, 200))
@ -111,7 +111,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewQp.Processed("test_profile")
@ -170,7 +170,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewQp.Processed("test_profile1", ("id1", 1, 100)),
@ -216,7 +216,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEquivalentTo([
NewQp.Processed("test_profile", ("id1", 1, 102), ("id2", 2, 201)) with
@ -247,7 +247,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEmpty();
}
@ -271,7 +271,7 @@ public class QualityProfileConfigPhaseTest
var context = new QualityProfilePipelineContext();
var sut = fixture.Create<QualityProfileConfigPhase>();
sut.Execute(context);
sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeEmpty();
}

@ -2,7 +2,6 @@ using NSubstitute.ReturnsExtensions;
using Recyclarr.Cli.Pipelines.QualitySize;
using Recyclarr.Cli.Pipelines.QualitySize.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Cli.Tests.Pipelines.QualitySize.PipelinePhases;
@ -11,20 +10,20 @@ namespace Recyclarr.Cli.Tests.Pipelines.QualitySize.PipelinePhases;
public class QualitySizeConfigPhaseTest
{
[Test, AutoMockData]
public void Do_nothing_if_no_quality_definition(
public async Task Do_nothing_if_no_quality_definition(
[Frozen] IServiceConfiguration config,
QualitySizeConfigPhase sut)
{
var context = new QualitySizePipelineContext();
config.QualityDefinition.ReturnsNull();
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeNull();
}
[Test, AutoMockData]
public void Do_nothing_if_no_matching_quality_definition(
public async Task Do_nothing_if_no_matching_quality_definition(
[Frozen] IQualitySizeGuideService guide,
[Frozen] IServiceConfiguration config,
QualitySizeConfigPhase sut)
@ -37,7 +36,7 @@ public class QualitySizeConfigPhaseTest
var context = new QualitySizePipelineContext();
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().BeNull();
}
@ -45,7 +44,7 @@ public class QualitySizeConfigPhaseTest
[Test]
[InlineAutoMockData("-0.1", "0")]
[InlineAutoMockData("1.1", "1")]
public void Preferred_ratio_clamping_works(
public async Task Preferred_ratio_clamping_works(
string testPreferred,
string expectedPreferred,
[Frozen] IQualitySizeGuideService guide,
@ -64,17 +63,16 @@ public class QualitySizeConfigPhaseTest
var context = new QualitySizePipelineContext();
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
config.QualityDefinition.Should().NotBeNull();
config.QualityDefinition!.PreferredRatio.Should().Be(decimal.Parse(expectedPreferred));
}
[Test, AutoMockData]
public void Preferred_is_set_via_ratio(
public async Task Preferred_is_set_via_ratio(
[Frozen] IQualitySizeGuideService guide,
[Frozen] IServiceConfiguration config,
[Frozen(Matching.ImplementedInterfaces)] TestQualityItemLimits limits,
QualitySizeConfigPhase sut)
{
config.QualityDefinition.Returns(new QualityDefinitionConfig
@ -83,7 +81,8 @@ public class QualitySizeConfigPhaseTest
PreferredRatio = 0.5m
});
guide.GetQualitySizeData(default!).ReturnsForAnyArgs([
guide.GetQualitySizeData(default!).ReturnsForAnyArgs(
[
new QualitySizeData
{
Type = "real",
@ -96,7 +95,7 @@ public class QualitySizeConfigPhaseTest
var context = new QualitySizePipelineContext();
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().NotBeNull();
context.ConfigOutput!.Qualities.Select(x => x.Item).Should().BeEquivalentTo([
@ -105,7 +104,7 @@ public class QualitySizeConfigPhaseTest
}
[Test, AutoMockData]
public void Preferred_is_set_via_guide(
public async Task Preferred_is_set_via_guide(
[Frozen] IQualitySizeGuideService guide,
[Frozen] IServiceConfiguration config,
QualitySizeConfigPhase sut)
@ -128,7 +127,7 @@ public class QualitySizeConfigPhaseTest
var context = new QualitySizePipelineContext();
sut.Execute(context);
await sut.Execute(context, CancellationToken.None);
context.ConfigOutput.Should().NotBeNull();
context.ConfigOutput!.Qualities.Select(x => x.Item).Should().BeEquivalentTo([

@ -7,6 +7,8 @@ public static class NewQualitySize
public static QualityItemWithLimits WithLimits(string quality, decimal min, decimal max, decimal preferred)
{
var item = new QualityItem(quality, min, max, preferred);
return new QualityItemWithLimits(item, new TestQualityItemLimits());
return new QualityItemWithLimits(item, new QualityItemLimits(
TestQualityItemLimits.MaxUnlimitedThreshold,
TestQualityItemLimits.PreferredUnlimitedThreshold));
}
}

@ -1,12 +1,7 @@
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Tests.TestLibrary;
public class TestQualityItemLimits : IQualityItemLimits
public static class TestQualityItemLimits
{
public const decimal MaxUnlimitedThreshold = 400m;
public const decimal PreferredUnlimitedThreshold = 400m;
public decimal MaxLimit { get; set; } = MaxUnlimitedThreshold;
public decimal PreferredLimit { get; set; } = PreferredUnlimitedThreshold;
}

@ -1,4 +1,5 @@
using Recyclarr.Tests.TestLibrary;
using Recyclarr.TrashGuide.QualitySize;
namespace Recyclarr.Tests.TrashGuide.QualitySize;
@ -70,14 +71,6 @@ public class QualityItemWithLimitsTest
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void Max_AboveThreshold_EqualsSameValue()
{
const decimal testVal = TestQualityItemLimits.MaxUnlimitedThreshold + 1;
var data = NewQualitySize.WithLimits("", 0, testVal, 0);
data.Item.Max.Should().Be(testVal);
}
[Test]
public void MaxForApi_AboveThreshold_EqualsNull()
{
@ -187,14 +180,6 @@ public class QualityItemWithLimitsTest
data.AnnotatedPreferred.Should().Be($"{testVal}");
}
[Test]
public void Preferred_AboveThreshold_EqualsSameValue()
{
const decimal testVal = TestQualityItemLimits.PreferredUnlimitedThreshold + 1;
var data = NewQualitySize.WithLimits("", 0, 0, testVal);
data.Item.Preferred.Should().Be(testVal);
}
[Test]
public void PreferredForApi_AboveThreshold_EqualsNull()
{
@ -217,4 +202,13 @@ public class QualityItemWithLimitsTest
var data = NewQualitySize.WithLimits("", 0, 0, 0);
data.PreferredForApi.Should().Be(0);
}
[Test]
public void Max_and_preferred_are_capped_when_over_limit()
{
var sut = new QualityItemWithLimits(new QualityItem("TestQuality", 10m, 100m, 100m),
new QualityItemLimits(50m, 70m));
sut.Item.Should().BeEquivalentTo(new QualityItem("TestQuality", 10m, 50m, 70m));
}
}

Loading…
Cancel
Save