feat: Quality definition logic uses new JSON files

pull/113/head
Robert Dailey 2 years ago
parent 635e93b2db
commit 07c4b48578

@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- Quality definition data is now pulled from JSON files.
## [2.3.1] - 2022-08-20
### Changed

@ -68,7 +68,7 @@ public class SonarrCommand : ServiceCommand
await profileUpdaterFactory().Process(Preview, config);
}
if (config.QualityDefinition.HasValue)
if (!string.IsNullOrEmpty(config.QualityDefinition))
{
await qualityUpdaterFactory().Process(Preview, config);
}

@ -14,17 +14,17 @@ public class RadarrQualityDataTest
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold, null, false},
new object?[] {RadarrQualityData.PreferredUnlimitedThreshold - 1, null, true},
new object?[] {RadarrQualityItem.PreferredUnlimitedThreshold, null, false},
new object?[] {RadarrQualityItem.PreferredUnlimitedThreshold - 1, null, true},
new object?[]
{RadarrQualityData.PreferredUnlimitedThreshold, RadarrQualityData.PreferredUnlimitedThreshold, true}
{RadarrQualityItem.PreferredUnlimitedThreshold, RadarrQualityItem.PreferredUnlimitedThreshold, true}
};
[TestCaseSource(nameof(PreferredTestValues))]
public void PreferredDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new RadarrQualityData {Preferred = guideValue};
var data = new RadarrQualityItem("", 0, 0, guideValue);
data.IsPreferredDifferent(radarrValue)
.Should().Be(isDifferent);
}
@ -35,19 +35,19 @@ public class RadarrQualityDataTest
{
400m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
RadarrQualityItem.PreferredUnlimitedThreshold
},
new[]
{
RadarrQualityData.PreferredUnlimitedThreshold,
RadarrQualityItem.PreferredUnlimitedThreshold,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold
RadarrQualityItem.PreferredUnlimitedThreshold
},
new[]
{
RadarrQualityData.PreferredUnlimitedThreshold - 1m,
RadarrQualityItem.PreferredUnlimitedThreshold - 1m,
1.0m,
RadarrQualityData.PreferredUnlimitedThreshold - 1m
RadarrQualityItem.PreferredUnlimitedThreshold - 1m
},
new[]
{
@ -67,54 +67,54 @@ public class RadarrQualityDataTest
public void InterpolatedPreferred_VariousValues_ExpectedResults(decimal max, decimal ratio,
decimal expectedResult)
{
var data = new RadarrQualityData {Min = 0, Max = max};
var data = new RadarrQualityItem("", 0, max, 0);
data.InterpolatedPreferred(ratio).Should().Be(expectedResult);
}
[Test]
public void AnnotatedPreferred_OutsideThreshold_EqualsSameValueWithUnlimited()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold;
var data = new RadarrQualityData {Preferred = testVal};
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold;
var data = new RadarrQualityItem("", 0, 0, testVal);
data.AnnotatedPreferred.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void AnnotatedPreferred_WithinThreshold_EqualsSameStringValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 1;
var data = new RadarrQualityData {Preferred = testVal};
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold - 1;
var data = new RadarrQualityItem("", 0, 0, testVal);
data.AnnotatedPreferred.Should().Be($"{testVal}");
}
[Test]
public void Preferred_AboveThreshold_EqualsSameValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityItem("", 0, 0, testVal);
data.Preferred.Should().Be(testVal);
}
[Test]
public void PreferredForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityData {Preferred = testVal};
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold + 1;
var data = new RadarrQualityItem("", 0, 0, testVal);
data.PreferredForApi.Should().Be(null);
}
[Test]
public void PreferredForApi_HighestWithinThreshold_EqualsSameValue()
{
const decimal testVal = RadarrQualityData.PreferredUnlimitedThreshold - 0.1m;
var data = new RadarrQualityData {Preferred = testVal};
const decimal testVal = RadarrQualityItem.PreferredUnlimitedThreshold - 0.1m;
var data = new RadarrQualityItem("", 0, 0, testVal);
data.PreferredForApi.Should().Be(testVal).And.Be(data.Preferred);
}
[Test]
public void PreferredForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new RadarrQualityData {Preferred = 0};
var data = new RadarrQualityItem("", 0, 0, 0);
data.PreferredForApi.Should().Be(0);
}
}

@ -6,7 +6,6 @@ using NUnit.Framework;
using TrashLib.Config;
using TrashLib.Services.Radarr;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Tests.Radarr;
@ -95,7 +94,7 @@ public class RadarrConfigurationTest
},
QualityDefinition = new QualityDefinitionConfig
{
Type = RadarrQualityDefinitionType.Movie
Type = "movie"
}
};

@ -1,6 +1,6 @@
using FluentAssertions;
using NUnit.Framework;
using TrashLib.Services.Sonarr.QualityDefinition;
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Tests.Sonarr.QualityDefinition;
@ -14,9 +14,9 @@ public class SonarrQualityDataTest
new object?[] {100m, 101m, true},
new object?[] {100m, 98m, true},
new object?[] {100m, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, null, false},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold - 1, null, true},
new object?[] {SonarrQualityData.MaxUnlimitedThreshold, SonarrQualityData.MaxUnlimitedThreshold, true}
new object?[] {QualityItem.MaxUnlimitedThreshold, null, false},
new object?[] {QualityItem.MaxUnlimitedThreshold - 1, null, true},
new object?[] {QualityItem.MaxUnlimitedThreshold, QualityItem.MaxUnlimitedThreshold, true}
};
private static readonly object[] MinTestValues =
@ -30,7 +30,7 @@ public class SonarrQualityDataTest
public void MaxDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal? radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Max = guideValue};
var data = new QualityItem("", 0, guideValue);
data.IsMaxDifferent(radarrValue)
.Should().Be(isDifferent);
}
@ -39,7 +39,7 @@ public class SonarrQualityDataTest
public void MinDifferent_WithVariousValues_ReturnsExpectedResult(decimal guideValue, decimal radarrValue,
bool isDifferent)
{
var data = new SonarrQualityData {Min = guideValue};
var data = new QualityItem("", guideValue, 0);
data.IsMinDifferent(radarrValue)
.Should().Be(isDifferent);
}
@ -47,16 +47,16 @@ public class SonarrQualityDataTest
[Test]
public void AnnotatedMax_OutsideThreshold_EqualsSameValueWithUnlimited()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold;
var data = new SonarrQualityData {Max = testVal};
const decimal testVal = QualityItem.MaxUnlimitedThreshold;
var data = new QualityItem("", 0, testVal);
data.AnnotatedMax.Should().Be($"{testVal} (Unlimited)");
}
[Test]
public void AnnotatedMax_WithinThreshold_EqualsSameStringValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 1;
var data = new SonarrQualityData {Max = testVal};
const decimal testVal = QualityItem.MaxUnlimitedThreshold - 1;
var data = new QualityItem("", 0, testVal);
data.AnnotatedMax.Should().Be($"{testVal}");
}
@ -64,38 +64,38 @@ public class SonarrQualityDataTest
public void AnnotatedMin_NoThreshold_EqualsSameValue()
{
const decimal testVal = 10m;
var data = new SonarrQualityData {Max = testVal};
var data = new QualityItem("", 0, testVal);
data.AnnotatedMax.Should().Be($"{testVal}");
}
[Test]
public void Max_AboveThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
const decimal testVal = QualityItem.MaxUnlimitedThreshold + 1;
var data = new QualityItem("", 0, testVal);
data.Max.Should().Be(testVal);
}
[Test]
public void MaxForApi_AboveThreshold_EqualsNull()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold + 1;
var data = new SonarrQualityData {Max = testVal};
const decimal testVal = QualityItem.MaxUnlimitedThreshold + 1;
var data = new QualityItem("", 0, testVal);
data.MaxForApi.Should().Be(null);
}
[Test]
public void MaxForApi_HighestWithinThreshold_EqualsSameValue()
{
const decimal testVal = SonarrQualityData.MaxUnlimitedThreshold - 0.1m;
var data = new SonarrQualityData {Max = testVal};
const decimal testVal = QualityItem.MaxUnlimitedThreshold - 0.1m;
var data = new QualityItem("", 0, testVal);
data.MaxForApi.Should().Be(testVal).And.Be(data.Max);
}
[Test]
public void MaxForApi_LowestWithinThreshold_EqualsSameValue()
{
var data = new SonarrQualityData {Max = 0};
var data = new QualityItem("", 0, 0);
data.MaxForApi.Should().Be(0);
}
}

@ -6,4 +6,6 @@ public interface IRepoPaths
{
IReadOnlyCollection<IDirectoryInfo> RadarrCustomFormatPaths { get; }
IReadOnlyCollection<IDirectoryInfo> SonarrReleaseProfilePaths { get; }
IReadOnlyCollection<IDirectoryInfo> SonarrQualityPaths { get; }
IReadOnlyCollection<IDirectoryInfo> RadarrQualityPaths { get; }
}

@ -1,11 +1,13 @@
namespace TrashLib.Repo;
public record RadarrMetadata(
IReadOnlyCollection<string> CustomFormats
IReadOnlyCollection<string> CustomFormats,
IReadOnlyCollection<string> Qualities
);
public record SonarrMetadata(
IReadOnlyCollection<string> ReleaseProfiles
IReadOnlyCollection<string> ReleaseProfiles,
IReadOnlyCollection<string> Qualities
);
public record JsonPaths(

@ -4,5 +4,7 @@ namespace TrashLib.Repo;
public record RepoPaths(
IReadOnlyCollection<IDirectoryInfo> RadarrCustomFormatPaths,
IReadOnlyCollection<IDirectoryInfo> SonarrReleaseProfilePaths
IReadOnlyCollection<IDirectoryInfo> SonarrReleaseProfilePaths,
IReadOnlyCollection<IDirectoryInfo> RadarrQualityPaths,
IReadOnlyCollection<IDirectoryInfo> SonarrQualityPaths
) : IRepoPaths;

@ -27,6 +27,9 @@ public class RepoPathsFactory : IRepoPathsFactory
var metadata = _metadata.Value;
return new RepoPaths(
ToDirectoryInfoList(metadata.JsonPaths.Radarr.CustomFormats),
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.ReleaseProfiles));
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.ReleaseProfiles),
ToDirectoryInfoList(metadata.JsonPaths.Radarr.Qualities),
ToDirectoryInfoList(metadata.JsonPaths.Sonarr.Qualities)
);
}
}

@ -0,0 +1,46 @@
using System.IO.Abstractions;
using Common.Extensions;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using Serilog;
namespace TrashLib.Services.Common.QualityDefinition;
internal class QualityGuideParser<T> where T : class
{
private readonly ILogger _log;
public QualityGuideParser(ILogger log)
{
_log = log;
}
public ICollection<T> GetQualities(IEnumerable<IDirectoryInfo> jsonDirectories)
{
return jsonDirectories
.SelectMany(x => x.GetFiles("*.json"))
.Select(ParseQuality)
.NotNull()
.ToList();
}
private T? ParseQuality(IFileInfo jsonFile)
{
var serializer = JsonSerializer.Create(new JsonSerializerSettings
{
ContractResolver = new DefaultContractResolver
{
NamingStrategy = new SnakeCaseNamingStrategy()
}
});
using var json = new JsonTextReader(jsonFile.OpenText());
var quality = serializer.Deserialize<T>(json);
if (quality is null)
{
_log.Debug("Failed to parse quality definition JSON file: {Filename}", jsonFile.FullName);
}
return quality;
}
}

@ -0,0 +1,46 @@
using System.Globalization;
using System.Text;
namespace TrashLib.Services.Common.QualityDefinition;
public class QualityItem
{
public QualityItem(string quality, decimal min, decimal max)
{
Quality = quality;
Min = min;
Max = max;
}
public const decimal MaxUnlimitedThreshold = 400;
public string Quality { get; }
public decimal Min { get; }
public decimal Max { get; }
public decimal? MaxForApi => Max < MaxUnlimitedThreshold ? Max : null;
public decimal MinForApi => Min;
public string AnnotatedMin => Min.ToString(CultureInfo.InvariantCulture);
public string AnnotatedMax => AnnotatedValue(Max, MaxUnlimitedThreshold);
protected static string AnnotatedValue(decimal value, decimal threshold)
{
var builder = new StringBuilder(value.ToString(CultureInfo.InvariantCulture));
if (value >= threshold)
{
builder.Append(" (Unlimited)");
}
return builder.ToString();
}
public bool IsMinDifferent(decimal serviceValue) => serviceValue != Min;
public bool IsMaxDifferent(decimal? serviceValue)
{
return serviceValue == null
? MaxUnlimitedThreshold != Max
: serviceValue != Max || MaxUnlimitedThreshold == Max;
}
}

@ -1,6 +1,5 @@
using JetBrains.Annotations;
using TrashLib.Config.Services;
using TrashLib.Services.Radarr.QualityDefinition;
namespace TrashLib.Services.Radarr.Config;
@ -31,9 +30,6 @@ public class QualityProfileConfig
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public class QualityDefinitionConfig
{
// -1 does not map to a valid enumerator. this is to force validation to fail if it is not set from YAML.
// All of this craziness is to avoid making the enum type nullable.
public RadarrQualityDefinitionType Type { get; init; } = (RadarrQualityDefinitionType) (-1);
public string Type { get; init; } = "";
public decimal PreferredRatio { get; set; } = 1.0m;
}

@ -46,6 +46,6 @@ internal class QualityDefinitionConfigValidator : AbstractValidator<QualityDefin
{
public QualityDefinitionConfigValidator(IRadarrValidationMessages messages)
{
RuleFor(x => x.Type).IsInEnum().WithMessage(messages.QualityDefinitionType);
RuleFor(x => x.Type).NotEmpty().WithMessage(messages.QualityDefinitionType);
}
}

@ -1,7 +0,0 @@
namespace TrashLib.Services.Radarr.QualityDefinition;
public interface IRadarrQualityDefinitionGuideParser
{
Task<string> GetMarkdownData();
IDictionary<RadarrQualityDefinitionType, List<RadarrQualityData>> ParseMarkdown(string markdown);
}

@ -0,0 +1,6 @@
namespace TrashLib.Services.Radarr.QualityDefinition;
public interface IRadarrQualityGuideParser
{
ICollection<RadarrQualityData> GetQualities();
}

@ -1,25 +1,7 @@
using TrashLib.Services.Sonarr.QualityDefinition;
namespace TrashLib.Services.Radarr.QualityDefinition;
public class RadarrQualityData : SonarrQualityData
{
public const decimal PreferredUnlimitedThreshold = 395;
public decimal Preferred { get; set; }
public decimal? PreferredForApi => Preferred < PreferredUnlimitedThreshold ? Preferred : null;
public string AnnotatedPreferred => AnnotatedValue(Preferred, PreferredUnlimitedThreshold);
public decimal InterpolatedPreferred(decimal ratio)
{
var cappedMax = Math.Min(Max, PreferredUnlimitedThreshold);
return Math.Round(Min + (cappedMax - Min) * ratio, 1);
}
public bool IsPreferredDifferent(decimal? serviceValue)
{
return serviceValue == null
? PreferredUnlimitedThreshold != Preferred
: serviceValue != Preferred || PreferredUnlimitedThreshold == Preferred;
}
}
public record RadarrQualityData(
string TrashId,
string Type,
IReadOnlyCollection<RadarrQualityItem> Qualities
);

@ -1,78 +0,0 @@
using System.IO.Abstractions;
using System.Text.RegularExpressions;
using Common.Extensions;
using TrashLib.Startup;
namespace TrashLib.Services.Radarr.QualityDefinition;
internal class RadarrQualityDefinitionGuideParser : IRadarrQualityDefinitionGuideParser
{
private readonly IAppPaths _paths;
private readonly Regex _regexHeader = new(@"^#+", RegexOptions.Compiled);
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
public RadarrQualityDefinitionGuideParser(IAppPaths paths)
{
_paths = paths;
}
public async Task<string> GetMarkdownData()
{
var repoDir = _paths.RepoDirectory;
var file = repoDir
.SubDirectory("docs")
.SubDirectory("Radarr")
.File("Radarr-Quality-Settings-File-Size.md").OpenText();
return await file.ReadToEndAsync();
}
public IDictionary<RadarrQualityDefinitionType, List<RadarrQualityData>> ParseMarkdown(string markdown)
{
var results = new Dictionary<RadarrQualityDefinitionType, List<RadarrQualityData>>();
List<RadarrQualityData>? table = null;
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
if (string.IsNullOrEmpty(line))
{
continue;
}
var match = _regexHeader.Match(line);
if (match.Success)
{
// todo: hard-coded for now since there's only one supported right now.
var type = RadarrQualityDefinitionType.Movie;
table = results.GetOrCreate(type);
// If we grab a table that isn't empty, that means for whatever reason *another* table
// in the markdown is trying to modify a previous table's data. For example, maybe there
// are two "Series" quality tables. That would be a weird edge case, but handle that
// here just in case.
if (table.Count > 0)
{
table = null;
}
}
else if (table != null)
{
match = _regexTableRow.Match(line);
if (match.Success)
{
table.Add(new RadarrQualityData
{
Name = match.Groups[1].Value,
Min = match.Groups[2].Value.ToDecimal(),
Max = match.Groups[3].Value.ToDecimal()
});
}
}
}
return results;
}
}

@ -1,6 +0,0 @@
namespace TrashLib.Services.Radarr.QualityDefinition;
public enum RadarrQualityDefinitionType
{
Movie
}

@ -1,4 +1,5 @@
using CliFx.Infrastructure;
using Common.Extensions;
using Serilog;
using TrashLib.Services.Radarr.Config;
using TrashLib.Services.Radarr.QualityDefinition.Api;
@ -8,58 +9,65 @@ namespace TrashLib.Services.Radarr.QualityDefinition;
internal class RadarrQualityDefinitionUpdater : IRadarrQualityDefinitionUpdater
{
private readonly ILogger _log;
private readonly IQualityDefinitionService _api;
private readonly IConsole _console;
private readonly IRadarrQualityDefinitionGuideParser _parser;
private readonly IRadarrQualityGuideParser _parser;
public RadarrQualityDefinitionUpdater(
ILogger logger,
IRadarrQualityDefinitionGuideParser parser,
IRadarrQualityGuideParser parser,
IQualityDefinitionService api,
IConsole console)
{
Log = logger;
_log = logger;
_parser = parser;
_api = api;
_console = console;
}
private ILogger Log { get; }
public async Task Process(bool isPreview, RadarrConfiguration config)
{
Log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition!.Type);
var qualityDefinitions = _parser.ParseMarkdown(await _parser.GetMarkdownData());
_log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition!.Type);
var qualityDefinitions = _parser.GetQualities();
var qualityTypeInConfig = config.QualityDefinition!.Type;
var selectedQuality = qualityDefinitions
.FirstOrDefault(x => x.Type.EqualsIgnoreCase(qualityTypeInConfig));
var selectedQuality = qualityDefinitions[config.QualityDefinition!.Type];
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}",
_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)
foreach (var quality in selectedQuality.Qualities)
{
quality.Preferred = quality.InterpolatedPreferred(config.QualityDefinition.PreferredRatio);
}
if (isPreview)
{
PrintQualityPreview(selectedQuality);
PrintQualityPreview(selectedQuality.Qualities);
return;
}
await ProcessQualityDefinition(selectedQuality);
await ProcessQualityDefinition(selectedQuality.Qualities);
}
private void PrintQualityPreview(IEnumerable<RadarrQualityData> quality)
private void PrintQualityPreview(IEnumerable<RadarrQualityItem> quality)
{
_console.Output.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-15} {3,-15}";
@ -68,22 +76,22 @@ internal class RadarrQualityDefinitionUpdater : IRadarrQualityDefinitionUpdater
foreach (var q in quality)
{
_console.Output.WriteLine(format, q.Name, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
_console.Output.WriteLine(format, q.Quality, q.AnnotatedMin, q.AnnotatedMax, q.AnnotatedPreferred);
}
_console.Output.WriteLine("");
}
private async Task ProcessQualityDefinition(IEnumerable<RadarrQualityData> guideQuality)
private async Task ProcessQualityDefinition(IEnumerable<RadarrQualityItem> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<RadarrQualityDefinitionItem> serverQuality,
IEnumerable<RadarrQualityData> guideQuality)
IEnumerable<RadarrQualityItem> guideQuality)
{
static bool QualityIsDifferent(RadarrQualityDefinitionItem a, RadarrQualityData b)
static bool QualityIsDifferent(RadarrQualityDefinitionItem a, RadarrQualityItem b)
{
return b.IsMinDifferent(a.MinSize) ||
b.IsMaxDifferent(a.MaxSize) ||
@ -93,10 +101,10 @@ internal class RadarrQualityDefinitionUpdater : IRadarrQualityDefinitionUpdater
var newQuality = new List<RadarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Name);
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.Name);
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
@ -111,12 +119,12 @@ internal class RadarrQualityDefinitionUpdater : IRadarrQualityDefinitionUpdater
entry.PreferredSize = qualityData.PreferredForApi;
newQuality.Add(entry);
Log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}] [Preferred: {Preferred}]",
_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);
_log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}

@ -0,0 +1,20 @@
using Serilog;
using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Services.Radarr.QualityDefinition;
internal class RadarrQualityGuideParser : IRadarrQualityGuideParser
{
private readonly QualityGuideParser<RadarrQualityData> _parser;
private readonly IRepoPathsFactory _pathFactory;
public RadarrQualityGuideParser(ILogger log, IRepoPathsFactory pathFactory)
{
_parser = new QualityGuideParser<RadarrQualityData>(log);
_pathFactory = pathFactory;
}
public ICollection<RadarrQualityData> GetQualities()
=> _parser.GetQualities(_pathFactory.Create().RadarrQualityPaths);
}

@ -0,0 +1,31 @@
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Services.Radarr.QualityDefinition;
public class RadarrQualityItem : QualityItem
{
public RadarrQualityItem(string quality, decimal min, decimal max, decimal preferred)
: base(quality, min, max)
{
Preferred = preferred;
}
public const decimal PreferredUnlimitedThreshold = 395;
public decimal Preferred { get; set; }
public decimal? PreferredForApi => Preferred < PreferredUnlimitedThreshold ? Preferred : null;
public string AnnotatedPreferred => AnnotatedValue(Preferred, PreferredUnlimitedThreshold);
public decimal InterpolatedPreferred(decimal ratio)
{
var cappedMax = Math.Min(Max, PreferredUnlimitedThreshold);
return Math.Round(Min + (cappedMax - Min) * ratio, 1);
}
public bool IsPreferredDifferent(decimal? serviceValue)
{
return serviceValue == null
? PreferredUnlimitedThreshold != Preferred
: serviceValue != Preferred || PreferredUnlimitedThreshold == Preferred;
}
}

@ -28,7 +28,7 @@ public class RadarrAutofacModule : Module
// Quality Definition Support
builder.RegisterType<RadarrQualityDefinitionUpdater>().As<IRadarrQualityDefinitionUpdater>();
builder.RegisterType<RadarrQualityDefinitionGuideParser>().As<IRadarrQualityDefinitionGuideParser>();
builder.RegisterType<RadarrQualityGuideParser>().As<IRadarrQualityGuideParser>();
// Custom Format Support
builder.RegisterType<CustomFormatUpdater>().As<ICustomFormatUpdater>();

@ -1,12 +1,11 @@
using TrashLib.Config.Services;
using TrashLib.Services.Sonarr.QualityDefinition;
namespace TrashLib.Services.Sonarr.Config;
public class SonarrConfiguration : ServiceConfiguration
{
public IList<ReleaseProfileConfig> ReleaseProfiles { get; init; } = Array.Empty<ReleaseProfileConfig>();
public SonarrQualityDefinitionType? QualityDefinition { get; init; }
public string QualityDefinition { get; init; } = "";
}
public class ReleaseProfileConfig

@ -1,7 +0,0 @@
namespace TrashLib.Services.Sonarr.QualityDefinition;
public interface ISonarrQualityDefinitionGuideParser
{
Task<string> GetMarkdownData();
IDictionary<SonarrQualityDefinitionType, List<SonarrQualityData>> ParseMarkdown(string markdown);
}

@ -0,0 +1,6 @@
namespace TrashLib.Services.Sonarr.QualityDefinition;
public interface ISonarrQualityGuideParser
{
ICollection<SonarrQualityData> GetQualities();
}

@ -1,39 +1,9 @@
using System.Globalization;
using System.Text;
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Services.Sonarr.QualityDefinition;
public class SonarrQualityData
{
public const decimal MaxUnlimitedThreshold = 400;
public string Name { get; set; } = "";
public decimal Min { get; set; }
public decimal Max { get; set; }
public decimal? MaxForApi => Max < MaxUnlimitedThreshold ? Max : null;
public decimal MinForApi => Min;
public string AnnotatedMin => Min.ToString(CultureInfo.InvariantCulture);
public string AnnotatedMax => AnnotatedValue(Max, MaxUnlimitedThreshold);
protected static string AnnotatedValue(decimal value, decimal threshold)
{
var builder = new StringBuilder(value.ToString(CultureInfo.InvariantCulture));
if (value >= threshold)
{
builder.Append(" (Unlimited)");
}
return builder.ToString();
}
public bool IsMinDifferent(decimal serviceValue) => serviceValue != Min;
public bool IsMaxDifferent(decimal? serviceValue)
{
return serviceValue == null
? MaxUnlimitedThreshold != Max
: serviceValue != Max || MaxUnlimitedThreshold == Max;
}
}
public record SonarrQualityData(
string TrashId,
string Type,
IReadOnlyCollection<QualityItem> Qualities
);

@ -1,81 +0,0 @@
using System.IO.Abstractions;
using System.Text.RegularExpressions;
using Common.Extensions;
using TrashLib.Startup;
namespace TrashLib.Services.Sonarr.QualityDefinition;
internal class SonarrQualityDefinitionGuideParser : ISonarrQualityDefinitionGuideParser
{
private readonly Regex _regexHeader = new(@"^#+", RegexOptions.Compiled);
private readonly Regex _regexTableRow =
new(@"\| *(.*?) *\| *([\d.]+) *\| *([\d.]+) *\|", RegexOptions.Compiled);
private readonly IAppPaths _paths;
public SonarrQualityDefinitionGuideParser(IAppPaths paths)
{
_paths = paths;
}
public async Task<string> GetMarkdownData()
{
var repoDir = _paths.RepoDirectory;
var file = repoDir
.SubDirectory("docs")
.SubDirectory("Sonarr")
.File("Sonarr-Quality-Settings-File-Size.md").OpenText();
return await file.ReadToEndAsync();
}
public IDictionary<SonarrQualityDefinitionType, List<SonarrQualityData>> ParseMarkdown(string markdown)
{
var results = new Dictionary<SonarrQualityDefinitionType, List<SonarrQualityData>>();
List<SonarrQualityData>? table = null;
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
if (string.IsNullOrEmpty(line))
{
continue;
}
var match = _regexHeader.Match(line);
if (match.Success)
{
var type = line.ContainsIgnoreCase("anime")
? SonarrQualityDefinitionType.Anime
: SonarrQualityDefinitionType.Series;
table = results.GetOrCreate(type);
// If we grab a table that isn't empty, that means for whatever reason *another* table
// in the markdown is trying to modify a previous table's data. For example, maybe there
// are two "Series" quality tables. That would be a weird edge case, but handle that
// here just in case.
if (table.Count > 0)
{
table = null;
}
}
else if (table != null)
{
match = _regexTableRow.Match(line);
if (match.Success)
{
table.Add(new SonarrQualityData
{
Name = match.Groups[1].Value,
Min = match.Groups[2].Value.ToDecimal(),
Max = match.Groups[3].Value.ToDecimal()
});
}
}
}
return results;
}
}

@ -1,8 +0,0 @@
namespace TrashLib.Services.Sonarr.QualityDefinition;
public enum SonarrQualityDefinitionType
{
Anime,
Series,
Hybrid
}

@ -1,6 +1,8 @@
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;
@ -9,91 +11,117 @@ namespace TrashLib.Services.Sonarr.QualityDefinition;
internal class SonarrQualityDefinitionUpdater : ISonarrQualityDefinitionUpdater
{
private readonly ILogger _log;
private readonly ISonarrApi _api;
private readonly IConsole _console;
private readonly ISonarrQualityDefinitionGuideParser _parser;
private readonly ISonarrQualityGuideParser _parser;
private readonly Regex _regexHybrid = new(@"720|1080", RegexOptions.Compiled);
public SonarrQualityDefinitionUpdater(
ILogger logger,
ISonarrQualityDefinitionGuideParser parser,
ISonarrQualityGuideParser parser,
ISonarrApi api,
IConsole console)
{
Log = logger;
_log = logger;
_parser = parser;
_api = api;
_console = console;
}
private ILogger Log { get; }
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 = _parser.ParseMarkdown(await _parser.GetMarkdownData());
List<SonarrQualityData> selectedQuality;
_log.Information("Processing Quality Definition: {QualityDefinition}", config.QualityDefinition);
var qualityDefinitions = _parser.GetQualities();
var qualityTypeInConfig = config.QualityDefinition;
// var qualityDefinitions = _parser.ParseMarkdown(await _parser.GetMarkdownData());
SonarrQualityData? selectedQuality;
if (config.QualityDefinition == SonarrQualityDefinitionType.Hybrid)
if (config.QualityDefinition.EqualsIgnoreCase("hybrid"))
{
selectedQuality = BuildHybridQuality(qualityDefinitions[SonarrQualityDefinitionType.Anime],
qualityDefinitions[SonarrQualityDefinitionType.Series]);
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[config.QualityDefinition!.Value];
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);
PrintQualityPreview(selectedQuality.Qualities);
return;
}
await ProcessQualityDefinition(selectedQuality);
await ProcessQualityDefinition(selectedQuality.Qualities);
}
private List<SonarrQualityData> BuildHybridQuality(IReadOnlyCollection<SonarrQualityData> anime,
IEnumerable<SonarrQualityData> series)
private SonarrQualityData BuildHybridQuality(
IReadOnlyCollection<QualityItem> anime,
IReadOnlyCollection<QualityItem> series)
{
// todo Verify anime & series are the same length? Probably not, because we might not care about some rows anyway.
Log.Information(
_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<SonarrQualityData>();
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.Name);
var match = _regexHybrid.Match(left.Quality);
if (!match.Success)
{
Log.Debug("Using 'Series' Quality For: {QualityName}", left.Name);
_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.Name == left.Name);
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.Name);
_log.Error("Could not find matching anime quality for series quality named {QualityName}",
left.Quality);
hybrid.Add(left);
continue;
}
hybrid.Add(new SonarrQualityData
{
Name = left.Name,
Min = Math.Min(left.Min, right.Min),
Max = Math.Max(left.Max, right.Max)
});
hybrid.Add(new QualityItem(left.Quality,
Math.Min(left.Min, right.Min),
Math.Max(left.Max, right.Max)));
}
return hybrid;
return new SonarrQualityData("", "hybrid", hybrid);
}
private void PrintQualityPreview(IEnumerable<SonarrQualityData> quality)
private void PrintQualityPreview(IEnumerable<QualityItem> quality)
{
_console.Output.WriteLine("");
const string format = "{0,-20} {1,-10} {2,-15}";
@ -102,22 +130,22 @@ internal class SonarrQualityDefinitionUpdater : ISonarrQualityDefinitionUpdater
foreach (var q in quality)
{
_console.Output.WriteLine(format, q.Name, q.AnnotatedMin, q.AnnotatedMax);
_console.Output.WriteLine(format, q.Quality, q.AnnotatedMin, q.AnnotatedMax);
}
_console.Output.WriteLine("");
}
private async Task ProcessQualityDefinition(IEnumerable<SonarrQualityData> guideQuality)
private async Task ProcessQualityDefinition(IEnumerable<QualityItem> guideQuality)
{
var serverQuality = await _api.GetQualityDefinition();
await UpdateQualityDefinition(serverQuality, guideQuality);
}
private async Task UpdateQualityDefinition(IReadOnlyCollection<SonarrQualityDefinitionItem> serverQuality,
IEnumerable<SonarrQualityData> guideQuality)
IEnumerable<QualityItem> guideQuality)
{
static bool QualityIsDifferent(SonarrQualityDefinitionItem a, SonarrQualityData b)
static bool QualityIsDifferent(SonarrQualityDefinitionItem a, QualityItem b)
{
return b.IsMinDifferent(a.MinSize) ||
b.IsMaxDifferent(a.MaxSize);
@ -126,10 +154,10 @@ internal class SonarrQualityDefinitionUpdater : ISonarrQualityDefinitionUpdater
var newQuality = new List<SonarrQualityDefinitionItem>();
foreach (var qualityData in guideQuality)
{
var entry = serverQuality.FirstOrDefault(q => q.Quality?.Name == qualityData.Name);
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.Name);
_log.Warning("Server lacks quality definition for {Quality}; it will be skipped", qualityData.Quality);
continue;
}
@ -143,12 +171,12 @@ internal class SonarrQualityDefinitionUpdater : ISonarrQualityDefinitionUpdater
entry.MaxSize = qualityData.MaxForApi;
newQuality.Add(entry);
Log.Debug("Setting Quality " +
"[Name: {Name}] [Source: {Source}] [Min: {Min}] [Max: {Max}]",
_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);
_log.Information("Number of updated qualities: {Count}", newQuality.Count);
}
}

@ -0,0 +1,20 @@
using Serilog;
using TrashLib.Repo;
using TrashLib.Services.Common.QualityDefinition;
namespace TrashLib.Services.Sonarr.QualityDefinition;
internal class SonarrQualityGuideParser : ISonarrQualityGuideParser
{
private readonly QualityGuideParser<SonarrQualityData> _parser;
private readonly IRepoPathsFactory _pathFactory;
public SonarrQualityGuideParser(ILogger log, IRepoPathsFactory pathFactory)
{
_parser = new QualityGuideParser<SonarrQualityData>(log);
_pathFactory = pathFactory;
}
public ICollection<SonarrQualityData> GetQualities()
=> _parser.GetQualities(_pathFactory.Create().SonarrQualityPaths);
}

@ -34,6 +34,6 @@ public class SonarrAutofacModule : Module
// Quality Definition Support
builder.RegisterType<SonarrQualityDefinitionUpdater>().As<ISonarrQualityDefinitionUpdater>();
builder.RegisterType<SonarrQualityDefinitionGuideParser>().As<ISonarrQualityDefinitionGuideParser>();
builder.RegisterType<SonarrQualityGuideParser>().As<ISonarrQualityGuideParser>();
}
}

@ -21,8 +21,4 @@
<ProjectReference Include="..\Common\Common.csproj" />
<ProjectReference Include="..\VersionControl\VersionControl.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Services" />
</ItemGroup>
</Project>

Loading…
Cancel
Save