feat!: Change Sonarr quality definitions config YAML to match Radarr

- Shares same syntax as Radarr
- Only Sonarr impacted
- Default behavior of `preferred_ratio` changed.
- Hybrid quality definition removed.
pull/151/head
Robert Dailey 1 year ago
parent ab352f6a4c
commit bbb2195df2

@ -8,6 +8,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
This release contains **BREAKING CHANGES**. See the [v4.0 Upgrade Guide][breaking4] for required
changes you need to make.
[breaking4]: https://recyclarr.dev/wiki/upgrade-guide/v4.0
### Changed
- **BREAKING**: Sonarr `quality_definition` configuration updated to address unexpected changes in
Sonarr v4 that caused it to stop working. See upgrade guide for details.
- Default for `preferred_ratio` changed from `1.0` to using the values from the guide.
### Removed
- **BREAKING**: Sonarr's `hybrid` quality definition removed.
### Fixed ### Fixed
- Do not warn about empty configuration YAML files when they aren't really empty. - Do not warn about empty configuration YAML files when they aren't really empty.

@ -104,6 +104,22 @@
} }
} }
}, },
"quality_definition": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string"
},
"preferred_ratio": {
"type": "number",
"default": 1.0,
"minimum": 0.0,
"maximum": 1.0
}
}
},
"radarr_instance": { "radarr_instance": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
@ -123,20 +139,7 @@
"description": "The API key from Radarr." "description": "The API key from Radarr."
}, },
"quality_definition": { "quality_definition": {
"type": "object", "$ref": "#/$defs/quality_definition"
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string"
},
"preferred_ratio": {
"type": "number",
"default": 1.0,
"minimum": 0.0,
"maximum": 1.0
}
}
}, },
"delete_old_custom_formats": { "delete_old_custom_formats": {
"$ref": "#/$defs/delete_old_custom_formats" "$ref": "#/$defs/delete_old_custom_formats"
@ -165,7 +168,7 @@
"description": "The API key from Sonarr." "description": "The API key from Sonarr."
}, },
"quality_definition": { "quality_definition": {
"type": "string" "$ref": "#/$defs/quality_definition"
}, },
"delete_old_custom_formats": { "delete_old_custom_formats": {
"$ref": "#/$defs/delete_old_custom_formats" "$ref": "#/$defs/delete_old_custom_formats"

@ -7,9 +7,9 @@ using Serilog;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Http; using TrashLib.Http;
using TrashLib.Services.CustomFormat; using TrashLib.Services.CustomFormat;
using TrashLib.Services.QualitySize;
using TrashLib.Services.Radarr; using TrashLib.Services.Radarr;
using TrashLib.Services.Radarr.Config; using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.QualityDefinition;
namespace Recyclarr.Command; namespace Recyclarr.Command;
@ -73,8 +73,8 @@ internal class RadarrCommand : ServiceCommand
if (config.QualityDefinition != null) if (config.QualityDefinition != null)
{ {
var updater = scope.Resolve<IRadarrQualityDefinitionUpdater>(); var updater = scope.Resolve<IQualitySizeUpdater>();
await updater.Process(Preview, config); await updater.Process(Preview, config.QualityDefinition, guideService);
} }
if (config.CustomFormats.Count > 0) if (config.CustomFormats.Count > 0)

@ -7,9 +7,9 @@ using Serilog;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Http; using TrashLib.Http;
using TrashLib.Services.CustomFormat; using TrashLib.Services.CustomFormat;
using TrashLib.Services.QualitySize;
using TrashLib.Services.Sonarr; using TrashLib.Services.Sonarr;
using TrashLib.Services.Sonarr.Config; using TrashLib.Services.Sonarr.Config;
using TrashLib.Services.Sonarr.QualityDefinition;
using TrashLib.Services.Sonarr.ReleaseProfile; using TrashLib.Services.Sonarr.ReleaseProfile;
using TrashLib.Services.Sonarr.ReleaseProfile.Guide; using TrashLib.Services.Sonarr.ReleaseProfile.Guide;
@ -108,10 +108,10 @@ public class SonarrCommand : ServiceCommand
await updater.Process(Preview, config); await updater.Process(Preview, config);
} }
if (!string.IsNullOrEmpty(config.QualityDefinition)) if (config.QualityDefinition != null)
{ {
var updater = scope.Resolve<ISonarrQualityDefinitionUpdater>(); var updater = scope.Resolve<IQualitySizeUpdater>();
await updater.Process(Preview, config); await updater.Process(Preview, config.QualityDefinition, guideService);
} }
if (config.CustomFormats.Count > 0) if (config.CustomFormats.Count > 0)

@ -58,7 +58,8 @@ public class ConfigurationLoader<T> : IConfigurationLoader<T>
switch (e.InnerException) switch (e.InnerException)
{ {
case InvalidCastException: case InvalidCastException:
_log.Error("Incompatible value assigned/used at line {Line}", line); _log.Error("Incompatible value assigned/used at line {Line}: {Msg}", line,
e.InnerException.Message);
break; break;
default: default:

@ -16,15 +16,18 @@
# Configuration specific to Sonarr # Configuration specific to Sonarr
sonarr: sonarr:
# Set the URL/API Key to your actual instance series:
- base_url: http://localhost:8989 # Set the URL/API Key to your actual instance
api_key: f7e74ba6c80046e39e076a27af5a8444 base_url: http://localhost:8989
api_key: YOUR_KEY_HERE
# Quality definitions from the guide to sync to Sonarr. Choice: anime, series, hybrid # Quality definitions from the guide to sync to Sonarr. Choices: series, anime
quality_definition: hybrid quality_definition:
type: series
# Release profiles from the guide to sync to Sonarr. # Release profiles from the guide to sync to Sonarr v3 (Sonarr v4 does not use this!)
# You can optionally add tags and make negative scores strictly ignored # Use `recyclarr sonarr --list-release-profiles` for values you can put here.
# https://trash-guides.info/Sonarr/Sonarr-Release-Profile-RegEx/
release_profiles: release_profiles:
# Series # Series
- trash_ids: - trash_ids:
@ -38,9 +41,10 @@ sonarr:
# Configuration specific to Radarr. # Configuration specific to Radarr.
radarr: radarr:
# Set the URL/API Key to your actual instance movies:
- base_url: http://localhost:7878 # Set the URL/API Key to your actual instance
api_key: bf99da49d0b0488ea34e4464aa63a0e5 base_url: http://localhost:7878
api_key: YOUR_KEY_HERE
# Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie' # Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie'
quality_definition: quality_definition:
@ -51,7 +55,9 @@ radarr:
delete_old_custom_formats: false delete_old_custom_formats: false
custom_formats: custom_formats:
# A list of custom formats to sync to Radarr. Must match the "trash_id" in the guide JSON. # A list of custom formats to sync to Radarr.
# Use `recyclarr radarr --list-custom-formats` for values you can put here.
# https://trash-guides.info/Radarr/Radarr-collection-of-custom-formats/
- trash_ids: - trash_ids:
- ed38b889b31be83fda192888e2286d83 # BR-DISK - ed38b889b31be83fda192888e2286d83 # BR-DISK
- 90cedc1fea7ea5d11298bebd3d1d3223 # EVO (no WEBDL) - 90cedc1fea7ea5d11298bebd3d1d3223 # EVO (no WEBDL)

@ -2,6 +2,8 @@ using System.Diagnostics.CodeAnalysis;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
namespace TrashLib.Tests;
[SetUpFixture] [SetUpFixture]
[SuppressMessage("ReSharper", "CheckNamespace")] [SuppressMessage("ReSharper", "CheckNamespace")]
public class GlobalTestSetup public class GlobalTestSetup

@ -1,12 +1,12 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using TrashLib.Services.Radarr.QualityDefinition; using TrashLib.Services.QualitySize;
namespace TrashLib.Tests.Radarr.QualityDefinition; namespace TrashLib.Tests.QualityDefinition;
[TestFixture] [TestFixture]
[Parallelizable(ParallelScope.All)] [Parallelizable(ParallelScope.All)]
public class RadarrQualityDataTest public class QualitySizeDataTest
{ {
private static readonly object[] PreferredTestValues = private static readonly object[] PreferredTestValues =
{ {
@ -14,17 +14,17 @@ public class RadarrQualityDataTest
new object?[] {100m, 101m, true}, new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true}, new object?[] {100m, 98m, true},
new object?[] {100m, null, true}, new object?[] {100m, null, true},
new object?[] {RadarrQualityItem.PreferredUnlimitedThreshold, null, false}, new object?[] {QualitySizeItem.PreferredUnlimitedThreshold, null, false},
new object?[] {RadarrQualityItem.PreferredUnlimitedThreshold - 1, null, true}, new object?[] {QualitySizeItem.PreferredUnlimitedThreshold - 1, null, true},
new object?[] new object?[]
{RadarrQualityItem.PreferredUnlimitedThreshold, RadarrQualityItem.PreferredUnlimitedThreshold, true} {QualitySizeItem.PreferredUnlimitedThreshold, QualitySizeItem.PreferredUnlimitedThreshold, true}
}; };
[TestCaseSource(nameof(PreferredTestValues))] [TestCaseSource(nameof(PreferredTestValues))]
public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue, public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent) bool isDifferent)
{ {
var data = new RadarrQualityItem("", 0, 0, guideValue); var data = new QualitySizeItem("", 0, 0, guideValue);
data.IsPreferredDifferent(radarrValue) data.IsPreferredDifferent(radarrValue)
.Should().Be(isDifferent); .Should().Be(isDifferent);
} }
@ -35,19 +35,19 @@ public class RadarrQualityDataTest
{ {
400m, 400m,
1.0m, 1.0m,
RadarrQualityItem.PreferredUnlimitedThreshold QualitySizeItem.PreferredUnlimitedThreshold
}, },
new[] new[]
{ {
RadarrQualityItem.PreferredUnlimitedThreshold, QualitySizeItem.PreferredUnlimitedThreshold,
1.0m, 1.0m,
RadarrQualityItem.PreferredUnlimitedThreshold QualitySizeItem.PreferredUnlimitedThreshold
}, },
new[] new[]
{ {
RadarrQualityItem.PreferredUnlimitedThreshold - 1m, QualitySizeItem.PreferredUnlimitedThreshold - 1m,
1.0m, 1.0m,
RadarrQualityItem.PreferredUnlimitedThreshold - 1m QualitySizeItem.PreferredUnlimitedThreshold - 1m
}, },
new[] new[]
{ {
@ -67,54 +67,54 @@ public class RadarrQualityDataTest
public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio, public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio,
decimal expectedResult) decimal expectedResult)
{ {
var data = new RadarrQualityItem("", 0, max, 0); var data = new QualitySizeItem("", 0, max, 0);
data.InterpolatedPreferred(ratio).Should().Be(expectedResult); data.InterpolatedPreferred(ratio).Should().Be(expectedResult);
} }
[Test] [Test]
public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited() public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
{ {
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold; const decimal testVal = QualitySizeItem.PreferredUnlimitedThreshold;
var data = new RadarrQualityItem("", 0, 0, testVal); var data = new QualitySizeItem("", 0, 0, testVal);
data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)"); data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)");
} }
[Test] [Test]
public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue() public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
{ {
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold - 1; const decimal testVal = QualitySizeItem.PreferredUnlimitedThreshold - 1;
var data = new RadarrQualityItem("", 0, 0, testVal); var data = new QualitySizeItem("", 0, 0, testVal);
data.AnnotatedPreferred.Should().Be($"{testVal}"); data.AnnotatedPreferred.Should().Be($"{testVal}");
} }
[Test] [Test]
public void Preferred_AboveThreshold_EqualsSameValue() public void Preferred_AboveThreshold_EqualsSameValue()
{ {
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold + 1; const decimal testVal = QualitySizeItem.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityItem("", 0, 0, testVal); var data = new QualitySizeItem("", 0, 0, testVal);
data.Preferred.Should().Be(testVal); data.Preferred.Should().Be(testVal);
} }
[Test] [Test]
public void PreferredForApi_AboveThreshold_EqualsNull() public void PreferredForApi_AboveThreshold_EqualsNull()
{ {
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold + 1; const decimal testVal = QualitySizeItem.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityItem("", 0, 0, testVal); var data = new QualitySizeItem("", 0, 0, testVal);
data.PreferredForApi.Should().Be(null); data.PreferredForApi.Should().Be(null);
} }
[Test] [Test]
public void PreferredForApi_HighestWithinThreshold_EqualsSameValue() public void PreferredForApi_HighestWithinThreshold_EqualsSameValue()
{ {
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold - 0.1m; const decimal testVal = QualitySizeItem.PreferredUnlimitedThreshold - 0.1m;
var data = new RadarrQualityItem("", 0, 0, testVal); var data = new QualitySizeItem("", 0, 0, testVal);
data.PreferredForApi.Should().Be(testVal).And.Be(data.Preferred); data.PreferredForApi.Should().Be(testVal).And.Be(data.Preferred);
} }
[Test] [Test]
public void PreferredForApi_LowestWithinThreshold_EqualsSameValue() public void PreferredForApi_LowestWithinThreshold_EqualsSameValue()
{ {
var data = new RadarrQualityItem("", 0, 0, 0); var data = new QualitySizeItem("", 0, 0, 0);
data.PreferredForApi.Should().Be(0); data.PreferredForApi.Should().Be(0);
} }
} }

@ -1,12 +1,12 @@
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
using TrashLib.Services.Common.QualityDefinition; using TrashLib.Services.QualitySize.Guide;
namespace TrashLib.Tests.Sonarr.QualityDefinition; namespace TrashLib.Tests.Sonarr.QualityDefinition;
[TestFixture] [TestFixture]
[Parallelizable(ParallelScope.All)] [Parallelizable(ParallelScope.All)]
public class SonarrQualityDataTest public class QualitySizeDataTest
{ {
private static readonly object[] MaxTestValues = private static readonly object[] MaxTestValues =
{ {

@ -16,6 +16,8 @@ public abstract class ServiceConfiguration : IServiceConfiguration
new List<CustomFormatConfig>(); new List<CustomFormatConfig>();
public bool DeleteOldCustomFormats { get; init; } public bool DeleteOldCustomFormats { get; init; }
public QualityDefinitionConfig? QualityDefinition { get; init; }
} }
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
@ -34,3 +36,10 @@ public class QualityProfileScoreConfig
public int? Score { get; init; } public int? Score { get; init; }
public bool ResetUnmatchedScores { get; init; } public bool ResetUnmatchedScores { get; init; }
} }
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
{
public string Type { get; init; } = "";
public decimal? PreferredRatio { get; set; }
}

@ -1,8 +1,10 @@
using TrashLib.Services.CustomFormat.Models; using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.QualitySize;
namespace TrashLib.Services.Common; namespace TrashLib.Services.Common;
public interface IGuideService public interface IGuideService
{ {
ICollection<CustomFormatData> GetCustomFormatData(); ICollection<CustomFormatData> GetCustomFormatData();
ICollection<QualitySizeData> GetQualities();
} }

@ -0,0 +1,7 @@
namespace TrashLib.Services.QualitySize.Api;
public interface IQualityDefinitionService
{
Task<List<ServiceQualityDefinitionItem>> GetQualityDefinition();
Task<IList<ServiceQualityDefinitionItem>> UpdateQualityDefinition(IList<ServiceQualityDefinitionItem> newQuality);
}

@ -1,8 +1,7 @@
using Flurl.Http; using Flurl.Http;
using TrashLib.Config.Services; using TrashLib.Config.Services;
using TrashLib.Services.Radarr.QualityDefinition.Api.Objects;
namespace TrashLib.Services.Radarr.QualityDefinition.Api; namespace TrashLib.Services.QualitySize.Api;
internal class QualityDefinitionService : IQualityDefinitionService internal class QualityDefinitionService : IQualityDefinitionService
{ {
@ -13,17 +12,17 @@ internal class QualityDefinitionService : IQualityDefinitionService
_service = service; _service = service;
} }
public async Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition() public async Task<List<ServiceQualityDefinitionItem>> GetQualityDefinition()
{ {
return await _service.Request("qualitydefinition") return await _service.Request("qualitydefinition")
.GetJsonAsync<List<RadarrQualityDefinitionItem>>(); .GetJsonAsync<List<ServiceQualityDefinitionItem>>();
} }
public async Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition( public async Task<IList<ServiceQualityDefinitionItem>> UpdateQualityDefinition(
IList<RadarrQualityDefinitionItem> newQuality) IList<ServiceQualityDefinitionItem> newQuality)
{ {
return await _service.Request("qualityDefinition", "update") return await _service.Request("qualityDefinition", "update")
.PutJsonAsync(newQuality) .PutJsonAsync(newQuality)
.ReceiveJson<List<RadarrQualityDefinitionItem>>(); .ReceiveJson<List<ServiceQualityDefinitionItem>>();
} }
} }

@ -1,9 +1,9 @@
using JetBrains.Annotations; using JetBrains.Annotations;
namespace TrashLib.Services.Radarr.QualityDefinition.Api.Objects; namespace TrashLib.Services.QualitySize.Api;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrQualityItem public class ServiceQualityItem
{ {
public int Id { get; set; } public int Id { get; set; }
public string Modifier { get; set; } = ""; public string Modifier { get; set; } = "";
@ -13,10 +13,10 @@ public class RadarrQualityItem
} }
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrQualityDefinitionItem public class ServiceQualityDefinitionItem
{ {
public int Id { get; set; } public int Id { get; set; }
public RadarrQualityItem? Quality { get; set; } public ServiceQualityItem? Quality { get; set; }
public string Title { get; set; } = ""; public string Title { get; set; } = "";
public int Weight { get; set; } public int Weight { get; set; }
public decimal MinSize { get; set; } public decimal MinSize { get; set; }

@ -1,7 +1,7 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
namespace TrashLib.Services.Common.QualityDefinition; namespace TrashLib.Services.QualitySize.Guide;
public class QualityItem public class QualityItem
{ {

@ -5,13 +5,13 @@ using Newtonsoft.Json;
using Newtonsoft.Json.Serialization; using Newtonsoft.Json.Serialization;
using Serilog; using Serilog;
namespace TrashLib.Services.Common.QualityDefinition; namespace TrashLib.Services.QualitySize.Guide;
internal class QualityGuideParser<T> where T : class internal class QualitySizeGuideParser<T> where T : class
{ {
private readonly ILogger _log; private readonly ILogger _log;
public QualityGuideParser(ILogger log) public QualitySizeGuideParser(ILogger log)
{ {
_log = log; _log = log;
} }

@ -0,0 +1,9 @@
using TrashLib.Config.Services;
using TrashLib.Services.Common;
namespace TrashLib.Services.QualitySize;
public interface IQualitySizeUpdater
{
Task Process(bool isPreview, QualityDefinitionConfig config, IGuideService guideService);
}

@ -0,0 +1,8 @@
namespace TrashLib.Services.QualitySize;
public record QualitySizeData(
// ReSharper disable once NotAccessedPositionalProperty.Global
string TrashId,
string Type,
IReadOnlyCollection<QualitySizeItem> Qualities
);

@ -1,10 +1,10 @@
using TrashLib.Services.Common.QualityDefinition; using TrashLib.Services.QualitySize.Guide;
namespace TrashLib.Services.Radarr.QualityDefinition; namespace TrashLib.Services.QualitySize;
public class RadarrQualityItem : QualityItem public class QualitySizeItem : QualityItem
{ {
public RadarrQualityItem(string quality, decimal min, decimal max, decimal preferred) public QualitySizeItem(string quality, decimal min, decimal max, decimal preferred)
: base(quality, min, max) : base(quality, min, max)
{ {
Preferred = preferred; Preferred = preferred;

@ -0,0 +1,127 @@
using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Config.Services;
using TrashLib.Services.Common;
using TrashLib.Services.QualitySize.Api;
namespace TrashLib.Services.QualitySize;
internal class QualitySizeUpdater : IQualitySizeUpdater
{
private readonly ILogger _log;
private readonly IQualityDefinitionService _api;
private readonly IConsole _console;
public QualitySizeUpdater(
ILogger logger,
IQualityDefinitionService api,
IConsole console)
{
_log = logger;
_api = api;
_console = console;
}
public async Task Process(bool isPreview, QualityDefinitionConfig config, IGuideService guideService)
{
_log.Information("Processing Quality Definition: {QualityDefinition}", config.Type);
var qualityDefinitions = guideService.GetQualities();
var qualityTypeInConfig = config.Type;
var selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityTypeInConfig));
if (selectedQuality == null)
{
_log.Error("The specified quality definition type does not exist: {Type}", qualityTypeInConfig);
return;
}
if (config.PreferredRatio is not null)
{
_log.Information("Using an explicit preferred ratio which will override values from the guide");
// Fix an out of range ratio and warn the user
if (config.PreferredRatio is < 0 or > 1)
{
var clampedRatio = Math.Clamp(config.PreferredRatio.Value, 0, 1);
_log.Warning("Your `preferred_ratio` of {CurrentRatio} is out of range. " +
"It must be a decimal between 0.0 and 1.0. It has been clamped to {ClampedRatio}",
config.PreferredRatio, clampedRatio);
config.PreferredRatio = clampedRatio;
}
// Apply a calculated preferred size
foreach (var quality in selectedQuality.Qualities)
{
quality.Preferred = quality.InterpolatedPreferred(config.PreferredRatio.Value);
}
}
if (isPreview)
{
PrintQualityPreview(selectedQuality.Qualities);
return;
}
await ProcessQualityDefinition(selectedQuality.Qualities);
}
private void PrintQualityPreview(IReadOnlyCollection<QualitySizeItem> quality)
{
_console.Output.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-15} {3,-15}";
_console.Output.WriteLine(format, "Quality", "Min", "Max", "Preferred");
_console.Output.WriteLine(format, "-------", "---", "---", "---------");
foreach (var q in quality)
{
_console.Output.WriteLine(format, q.Quality, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
}
_console.Output.WriteLine("");
}
private static bool QualityIsDifferent(ServiceQualityDefinitionItem a, QualitySizeItem b)
{
return b.IsMinDifferent(a.MinSize) || b.IsMaxDifferent(a.MaxSize) ||
a.PreferredSize is not null && b.IsPreferredDifferent(a.PreferredSize);
}
private async Task ProcessQualityDefinition(IReadOnlyCollection<QualitySizeItem> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
var newQuality = new List<ServiceQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var serverEntry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Quality);
if (serverEntry == null)
{
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
if (!QualityIsDifferent(serverEntry, qualityData))
{
continue;
}
// Not using the original list again, so it's OK to modify the definition ref type objects in-place.
serverEntry.MinSize = qualityData.MinForApi;
serverEntry.MaxSize = qualityData.MaxForApi;
serverEntry.PreferredSize = qualityData.PreferredForApi;
newQuality.Add(serverEntry);
_log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}] [Preferred: {Preferred}]",
serverEntry.Quality?.Name, serverEntry.Quality?.Source, serverEntry.MinSize, serverEntry.MaxSize,
serverEntry.PreferredSize);
}
await _api.UpdateQualityDefinition(newQuality);
_log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}

@ -6,12 +6,4 @@ namespace TrashLib.Services.Radarr.Config;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class RadarrConfiguration : ServiceConfiguration public class RadarrConfiguration : ServiceConfiguration
{ {
public QualityDefinitionConfig? QualityDefinition { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
{
public string Type { get; init; } = "";
public decimal PreferredRatio { get; set; } = 1.0m;
} }

@ -1,9 +1,7 @@
using TrashLib.Services.Common; using TrashLib.Services.Common;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr; namespace TrashLib.Services.Radarr;
public interface IRadarrGuideService : IGuideService public interface IRadarrGuideService : IGuideService
{ {
ICollection<RadarrQualityData> GetQualities();
} }

@ -1,9 +1,9 @@
using Serilog; using Serilog;
using TrashLib.Repo; using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
using TrashLib.Services.CustomFormat.Guide; using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Models; using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.Radarr.QualityDefinition; using TrashLib.Services.QualitySize;
using TrashLib.Services.QualitySize.Guide;
namespace TrashLib.Services.Radarr; namespace TrashLib.Services.Radarr;
@ -11,16 +11,16 @@ public class LocalRepoRadarrGuideService : IRadarrGuideService
{ {
private readonly IRepoPathsFactory _pathsFactory; private readonly IRepoPathsFactory _pathsFactory;
private readonly ICustomFormatLoader _cfLoader; private readonly ICustomFormatLoader _cfLoader;
private readonly QualityGuideParser<RadarrQualityData> _parser; private readonly QualitySizeGuideParser<QualitySizeData> _parser;
public LocalRepoRadarrGuideService(IRepoPathsFactory pathsFactory, ILogger log, ICustomFormatLoader cfLoader) public LocalRepoRadarrGuideService(IRepoPathsFactory pathsFactory, ILogger log, ICustomFormatLoader cfLoader)
{ {
_pathsFactory = pathsFactory; _pathsFactory = pathsFactory;
_cfLoader = cfLoader; _cfLoader = cfLoader;
_parser = new QualityGuideParser<RadarrQualityData>(log); _parser = new QualitySizeGuideParser<QualitySizeData>(log);
} }
public ICollection<RadarrQualityData> GetQualities() public ICollection<QualitySizeData> GetQualities()
=> _parser.GetQualities(_pathsFactory.Create().RadarrQualityPaths); => _parser.GetQualities(_pathsFactory.Create().RadarrQualityPaths);
public ICollection<CustomFormatData> GetCustomFormatData() public ICollection<CustomFormatData> GetCustomFormatData()

@ -1,9 +0,0 @@
using TrashLib.Services.Radarr.QualityDefinition.Api.Objects;
namespace TrashLib.Services.Radarr.QualityDefinition.Api;
public interface IQualityDefinitionService
{
Task<List<RadarrQualityDefinitionItem>> GetQualityDefinition();
Task<IList<RadarrQualityDefinitionItem>> UpdateQualityDefinition(IList<RadarrQualityDefinitionItem> newQuality);
}

@ -1,8 +0,0 @@
using TrashLib.Services.Radarr.Config;
namespace TrashLib.Services.Radarr.QualityDefinition;
public interface IRadarrQualityDefinitionUpdater
{
Task Process(bool isPreview, RadarrConfiguration config);
}

@ -1,8 +0,0 @@
namespace TrashLib.Services.Radarr.QualityDefinition;
public record RadarrQualityData(
// ReSharper disable once NotAccessedPositionalProperty.Global
string TrashId,
string Type,
IReadOnlyCollection<RadarrQualityItem> Qualities
);

@ -1,130 +0,0 @@
using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.QualityDefinition.Api;
using TrashLib.Services.Radarr.QualityDefinition.Api.Objects;
namespace TrashLib.Services.Radarr.QualityDefinition;
internal class RadarrQualityDefinitionUpdater : IRadarrQualityDefinitionUpdater
{
private readonly ILogger _log;
private readonly IQualityDefinitionService _api;
private readonly IConsole _console;
private readonly IRadarrGuideService _guide;
public RadarrQualityDefinitionUpdater(
ILogger logger,
IRadarrGuideService guide,
IQualityDefinitionService api,
IConsole console)
{
_log = logger;
_guide = guide;
_api = api;
_console = console;
}
public async Task Process(bool isPreview, RadarrConfiguration config)
{
_log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition!.Type);
var qualityDefinitions = _guide.GetQualities();
var qualityTypeInConfig = config.QualityDefinition!.Type;
var selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityTypeInConfig));
if (selectedQuality == null)
{
_log.Error("The specified quality definition type does not exist: {Type}", qualityTypeInConfig);
return;
}
// Fix an out of range ratio and warn the user
if (config.QualityDefinition.PreferredRatio is < 0 or > 1)
{
var clampedRatio = Math.Clamp(config.QualityDefinition.PreferredRatio, 0, 1);
_log.Warning("Your `preferred_ratio` of {CurrentRatio} is out of range. " +
"It must be a decimal between 0.0 and 1.0. It has been clamped to {ClampedRatio}",
config.QualityDefinition.PreferredRatio, clampedRatio);
config.QualityDefinition.PreferredRatio = clampedRatio;
}
// Apply a calculated preferred size
foreach (var quality in selectedQuality.Qualities)
{
quality.Preferred = quality.InterpolatedPreferred(config.QualityDefinition.PreferredRatio);
}
if (isPreview)
{
PrintQualityPreview(selectedQuality.Qualities);
return;
}
await ProcessQualityDefinition(selectedQuality.Qualities);
}
private void PrintQualityPreview(IEnumerable<RadarrQualityItem> quality)
{
_console.Output.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-15} {3,-15}";
_console.Output.WriteLine(format, "Quality", "Min", "Max", "Preferred");
_console.Output.WriteLine(format, "-------", "---", "---", "---------");
foreach (var q in quality)
{
_console.Output.WriteLine(format, q.Quality, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
}
_console.Output.WriteLine("");
}
private async Task ProcessQualityDefinition(IEnumerable<RadarrQualityItem> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<RadarrQualityDefinitionItem> serverQuality,
IEnumerable<RadarrQualityItem> guideQuality)
{
static bool QualityIsDifferent(RadarrQualityDefinitionItem a, RadarrQualityItem b)
{
return b.IsMinDifferent(a.MinSize) ||
b.IsMaxDifferent(a.MaxSize) ||
b.IsPreferredDifferent(a.PreferredSize);
}
var newQuality = new List<RadarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Quality);
if (entry == null)
{
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
if (!QualityIsDifferent(entry, qualityData))
{
continue;
}
// Not using the original list again, so it's OK to modify the definition ref type objects in-place.
entry.MinSize = qualityData.MinForApi;
entry.MaxSize = qualityData.MaxForApi;
entry.PreferredSize = qualityData.PreferredForApi;
newQuality.Add(entry);
_log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}] [Preferred: {Preferred}]",
entry.Quality?.Name, entry.Quality?.Source, entry.MinSize, entry.MaxSize, entry.PreferredSize);
}
await _api.UpdateQualityDefinition(newQuality);
_log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}

@ -1,7 +1,7 @@
using Autofac; using Autofac;
using TrashLib.Services.QualitySize;
using TrashLib.Services.QualitySize.Api;
using TrashLib.Services.Radarr.Config; using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.QualityDefinition;
using TrashLib.Services.Radarr.QualityDefinition.Api;
namespace TrashLib.Services.Radarr; namespace TrashLib.Services.Radarr;
@ -12,7 +12,7 @@ public class RadarrAutofacModule : Module
builder.RegisterType<QualityDefinitionService>().As<IQualityDefinitionService>(); builder.RegisterType<QualityDefinitionService>().As<IQualityDefinitionService>();
builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>(); builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>();
builder.RegisterType<RadarrValidationMessages>().As<IRadarrValidationMessages>(); builder.RegisterType<RadarrValidationMessages>().As<IRadarrValidationMessages>();
builder.RegisterType<RadarrQualityDefinitionUpdater>().As<IRadarrQualityDefinitionUpdater>(); builder.RegisterType<QualitySizeUpdater>().As<IQualitySizeUpdater>();
builder.RegisterType<LocalRepoRadarrGuideService>().As<IRadarrGuideService>(); builder.RegisterType<LocalRepoRadarrGuideService>().As<IRadarrGuideService>();
builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>(); builder.RegisterType<RadarrGuideDataLister>().As<IRadarrGuideDataLister>();
builder.RegisterType<RadarrCompatibility>(); builder.RegisterType<RadarrCompatibility>();

@ -7,8 +7,6 @@ public class SonarrConfiguration : ServiceConfiguration
{ {
public IList<ReleaseProfileConfig> ReleaseProfiles { get; [UsedImplicitly] init; } = public IList<ReleaseProfileConfig> ReleaseProfiles { get; [UsedImplicitly] init; } =
Array.Empty<ReleaseProfileConfig>(); Array.Empty<ReleaseProfileConfig>();
public string QualityDefinition { get; [UsedImplicitly] init; } = "";
} }
public class ReleaseProfileConfig public class ReleaseProfileConfig

@ -1,8 +0,0 @@
using TrashLib.Services.Sonarr.Config;
namespace TrashLib.Services.Sonarr.QualityDefinition;
public interface ISonarrQualityDefinitionUpdater
{
Task Process(bool isPreview, SonarrConfiguration config);
}

@ -1,10 +0,0 @@
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Services.Sonarr.QualityDefinition;
public record SonarrQualityData(
// ReSharper disable once NotAccessedPositionalProperty.Global
string TrashId,
string Type,
IReadOnlyCollection<QualityItem> Qualities
);

@ -1,181 +0,0 @@
using System.Text.RegularExpressions;
using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Services.Common.QualityDefinition;
using TrashLib.Services.Sonarr.Api;
using TrashLib.Services.Sonarr.Api.Objects;
using TrashLib.Services.Sonarr.Config;
using TrashLib.Services.Sonarr.ReleaseProfile.Guide;
namespace TrashLib.Services.Sonarr.QualityDefinition;
internal class SonarrQualityDefinitionUpdater : ISonarrQualityDefinitionUpdater
{
private readonly ILogger _log;
private readonly ISonarrApi _api;
private readonly IConsole _console;
private readonly ISonarrGuideService _guide;
private readonly Regex _regexHybrid = new(@"720|1080", RegexOptions.Compiled);
public SonarrQualityDefinitionUpdater(
ILogger logger,
ISonarrGuideService guide,
ISonarrApi api,
IConsole console)
{
_log = logger;
_guide = guide;
_api = api;
_console = console;
}
private SonarrQualityData? GetQualityOrError(ICollection<SonarrQualityData> qualityDefinitions, string type)
{
var quality = qualityDefinitions.FirstOrDefault(x => x.Type.EqualsIgnoreCase(type));
if (quality is null)
{
_log.Error(
"The following quality definition is required for hybrid, but was not found in the guide: {Type}",
type);
}
return quality;
}
public async Task Process(bool isPreview, SonarrConfiguration config)
{
_log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition);
var qualityDefinitions = _guide.GetQualities();
var qualityTypeInConfig = config.QualityDefinition;
SonarrQualityData? selectedQuality;
if (config.QualityDefinition.EqualsIgnoreCase("hybrid"))
{
var animeQuality = GetQualityOrError(qualityDefinitions, "anime");
var seriesQuality = GetQualityOrError(qualityDefinitions, "series");
if (animeQuality is null || seriesQuality is null)
{
return;
}
selectedQuality = BuildHybridQuality(animeQuality.Qualities, seriesQuality.Qualities);
}
else
{
selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityTypeInConfig));
if (selectedQuality == null)
{
_log.Error("The specified quality definition type does not exist: {Type}", qualityTypeInConfig);
return;
}
}
if (isPreview)
{
PrintQualityPreview(selectedQuality.Qualities);
return;
}
await ProcessQualityDefinition(selectedQuality.Qualities);
}
private SonarrQualityData BuildHybridQuality(
IReadOnlyCollection<QualityItem> anime,
IReadOnlyCollection<QualityItem> series)
{
_log.Information(
"Notice: Hybrid only functions on 720/1080 qualities and uses non-anime values for the rest (e.g. 2160)");
var hybrid = new List<QualityItem>();
foreach (var left in series)
{
// Any qualities that anime doesn't care about get immediately added from Series quality
var match = _regexHybrid.Match(left.Quality);
if (!match.Success)
{
_log.Debug("Using 'Series' Quality For: {QualityName}", left.Quality);
hybrid.Add(left);
continue;
}
// If there's a quality in Series that Anime doesn't know about, we add the Series quality
var right = anime.FirstOrDefault(row => row.Quality == left.Quality);
if (right == null)
{
_log.Error("Could not find matching anime quality for series quality named {QualityName}",
left.Quality);
hybrid.Add(left);
continue;
}
hybrid.Add(new QualityItem(left.Quality,
Math.Min(left.Min, right.Min),
Math.Max(left.Max, right.Max)));
}
return new SonarrQualityData("", "hybrid", hybrid);
}
private void PrintQualityPreview(IEnumerable<QualityItem> quality)
{
_console.Output.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-15}";
_console.Output.WriteLine(format, "Quality", "Min", "Max");
_console.Output.WriteLine(format, "-------", "---", "---");
foreach (var q in quality)
{
_console.Output.WriteLine(format, q.Quality, q.AnnotatedMin, q.AnnotatedMax);
}
_console.Output.WriteLine("");
}
private async Task ProcessQualityDefinition(IEnumerable<QualityItem> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<SonarrQualityDefinitionItem> serverQuality,
IEnumerable<QualityItem> guideQuality)
{
static bool QualityIsDifferent(SonarrQualityDefinitionItem a, QualityItem b)
{
return b.IsMinDifferent(a.MinSize) ||
b.IsMaxDifferent(a.MaxSize);
}
var newQuality = new List<SonarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Quality);
if (entry == null)
{
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
if (!QualityIsDifferent(entry, qualityData))
{
continue;
}
// Not using the original list again, so it's OK to modify the definition ref type objects in-place.
entry.MinSize = qualityData.MinForApi;
entry.MaxSize = qualityData.MaxForApi;
newQuality.Add(entry);
_log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}]",
entry.Quality?.Name, entry.Quality?.Source, entry.MinSize, entry.MaxSize);
}
await _api.UpdateQualityDefinition(newQuality);
_log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}

@ -1,5 +1,4 @@
using TrashLib.Services.Common; using TrashLib.Services.Common;
using TrashLib.Services.Sonarr.QualityDefinition;
namespace TrashLib.Services.Sonarr.ReleaseProfile.Guide; namespace TrashLib.Services.Sonarr.ReleaseProfile.Guide;
@ -7,5 +6,4 @@ public interface ISonarrGuideService : IGuideService
{ {
IReadOnlyCollection<ReleaseProfileData> GetReleaseProfileData(); IReadOnlyCollection<ReleaseProfileData> GetReleaseProfileData();
ReleaseProfileData? GetUnfilteredProfileById(string trashId); ReleaseProfileData? GetUnfilteredProfileById(string trashId);
ICollection<SonarrQualityData> GetQualities();
} }

@ -5,10 +5,10 @@ using MoreLinq;
using Newtonsoft.Json; using Newtonsoft.Json;
using Serilog; using Serilog;
using TrashLib.Repo; using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
using TrashLib.Services.CustomFormat.Guide; using TrashLib.Services.CustomFormat.Guide;
using TrashLib.Services.CustomFormat.Models; using TrashLib.Services.CustomFormat.Models;
using TrashLib.Services.Sonarr.QualityDefinition; using TrashLib.Services.QualitySize;
using TrashLib.Services.QualitySize.Guide;
using TrashLib.Services.Sonarr.ReleaseProfile.Filters; using TrashLib.Services.Sonarr.ReleaseProfile.Filters;
namespace TrashLib.Services.Sonarr.ReleaseProfile.Guide; namespace TrashLib.Services.Sonarr.ReleaseProfile.Guide;
@ -19,7 +19,7 @@ public class LocalRepoSonarrGuideService : ISonarrGuideService
private readonly ILogger _log; private readonly ILogger _log;
private readonly ICustomFormatLoader _cfLoader; private readonly ICustomFormatLoader _cfLoader;
private readonly Lazy<IEnumerable<ReleaseProfileData>> _data; private readonly Lazy<IEnumerable<ReleaseProfileData>> _data;
private readonly QualityGuideParser<SonarrQualityData> _parser; private readonly QualitySizeGuideParser<QualitySizeData> _parser;
public LocalRepoSonarrGuideService( public LocalRepoSonarrGuideService(
IRepoPathsFactory pathsFactory, IRepoPathsFactory pathsFactory,
@ -30,10 +30,10 @@ public class LocalRepoSonarrGuideService : ISonarrGuideService
_log = log; _log = log;
_cfLoader = cfLoader; _cfLoader = cfLoader;
_data = new Lazy<IEnumerable<ReleaseProfileData>>(GetReleaseProfileDataImpl); _data = new Lazy<IEnumerable<ReleaseProfileData>>(GetReleaseProfileDataImpl);
_parser = new QualityGuideParser<SonarrQualityData>(log); _parser = new QualitySizeGuideParser<QualitySizeData>(log);
} }
public ICollection<SonarrQualityData> GetQualities() public ICollection<QualitySizeData> GetQualities()
=> _parser.GetQualities(_pathsFactory.Create().SonarrQualityPaths); => _parser.GetQualities(_pathsFactory.Create().SonarrQualityPaths);
public ICollection<CustomFormatData> GetCustomFormatData() public ICollection<CustomFormatData> GetCustomFormatData()

@ -2,7 +2,6 @@ using Autofac;
using Autofac.Extras.Ordering; using Autofac.Extras.Ordering;
using TrashLib.Services.Sonarr.Api; using TrashLib.Services.Sonarr.Api;
using TrashLib.Services.Sonarr.Config; using TrashLib.Services.Sonarr.Config;
using TrashLib.Services.Sonarr.QualityDefinition;
using TrashLib.Services.Sonarr.ReleaseProfile; using TrashLib.Services.Sonarr.ReleaseProfile;
using TrashLib.Services.Sonarr.ReleaseProfile.Filters; using TrashLib.Services.Sonarr.ReleaseProfile.Filters;
using TrashLib.Services.Sonarr.ReleaseProfile.Guide; using TrashLib.Services.Sonarr.ReleaseProfile.Guide;
@ -33,8 +32,5 @@ public class SonarrAutofacModule : Module
typeof(StrictNegativeScoresFilter)) typeof(StrictNegativeScoresFilter))
.As<IReleaseProfileFilter>() .As<IReleaseProfileFilter>()
.OrderByRegistration(); .OrderByRegistration();
// Quality Definition Support
builder.RegisterType<SonarrQualityDefinitionUpdater>().As<ISonarrQualityDefinitionUpdater>();
} }
} }

Loading…
Cancel
Save