feat(sonarr)!: Parse Guide Release Profile JSON

Previously, Trash Updater would crawl & parse the Trash Guide's markdown
files to obtain information about release profiles. This is complex and
error prone. Thanks to work done by Nitsua, we now have JSON files
available that describe release profiles in a more concise way. These
files are located at `docs/json/sonarr` in the [Trash Guide repo][1].

All of the markdown parsing code has been removed from Trash Updater.
Now, it shares the same git clone of the Trash Guide repository
originally used for Radarr custom formats to access those release
profile JSON files.

BREAKING CHANGE: The old `type:` property for release profiles is
removed in favor of `trash_id:`, which identifies a specific JSON file
to pull data from. Users are required to update their `trash.yml` and
other configuration files to use the new schema. Until changes are made,
users will see errors when they run `trash sonarr` commands.

[1]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarr
pull/56/head
Robert Dailey 2 years ago
parent e86b83c9ab
commit 434158f7a6

@ -8,18 +8,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Linux MUSL builds for arm, arm64, and x64. Main target for this was supporting Alpine Linux in
Docker.
This release contains **BREAKING CHANGES**. See the [Upgrade Guide] for required changes you need to
make.
### Changed
- **BREAKING**: Sonarr Release profiles are now synced based on a "Trash ID" taken from [the sonarr
JSON files][sonarrjson]. This breaks existing `trash.yml` and manual changes *are required*.
- Do not follow HTTP redirects and instead issue a warning to the user that they are potentially
using the wrong URL.
- Radarr: Sanitize URLs in HTTP exception messages ([#17]).
- Sonarr: Release profiles starting with `[Trash]` but are not specified in the config are deleted.
### Added
- Linux MUSL builds for arm, arm64, and x64. Main target for this was supporting Alpine Linux in
Docker.
- Sonarr: Ability to include or exclude specific optional Required, Ignored, or Preferred terms in
release profiles.
[#17]: https://github.com/rcdailey/trash-updater/issues/17
[Upgrade Guide]: https://github.com/rcdailey/trash-updater/wiki/Upgrade-Guide
[sonarrjson]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarr
## [1.8.2] - 2022-03-06

@ -1,39 +0,0 @@
using FluentValidation;
using FluentValidation.Validators;
namespace Common.Extensions;
public static class FluentValidationExtensions
{
// From: https://github.com/FluentValidation/FluentValidation/issues/1648
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
{
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
{
RuleSets = ruleSets
};
return ruleBuilder.SetAsyncValidator(adapter);
}
private sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
{
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
: base(validator, validatorType)
{
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
}
}

@ -1,6 +1,6 @@
using Newtonsoft.Json.Linq;
namespace Common;
namespace Common.Extensions;
public static class JsonNetExtensions
{

@ -4,14 +4,24 @@ namespace Common.Extensions;
public static class StringExtensions
{
public static bool ContainsIgnoreCase(this string value, string searchFor)
public static bool ContainsIgnoreCase(this string? value, string searchFor)
{
return value.Contains(searchFor, StringComparison.OrdinalIgnoreCase);
return value?.Contains(searchFor, StringComparison.OrdinalIgnoreCase) ?? false;
}
public static bool EqualsIgnoreCase(this string value, string? matchThis)
public static bool EqualsIgnoreCase(this string? value, string? matchThis)
{
return value.Equals(matchThis, StringComparison.OrdinalIgnoreCase);
return value?.Equals(matchThis, StringComparison.OrdinalIgnoreCase) ?? false;
}
public static bool EndsWithIgnoreCase(this string? value, string matchThis)
{
return value?.EndsWith(matchThis, StringComparison.OrdinalIgnoreCase) ?? false;
}
public static bool StartsWithIgnoreCase(this string? value, string matchThis)
{
return value?.StartsWith(matchThis, StringComparison.OrdinalIgnoreCase) ?? false;
}
public static float ToFloat(this string value)

@ -0,0 +1,38 @@
using FluentValidation;
using FluentValidation.Results;
namespace Common.FluentValidation;
public static class FluentValidationExtensions
{
// From: https://github.com/FluentValidation/FluentValidation/issues/1648
public static IRuleBuilderOptions<T, TProperty?> SetNonNullableValidator<T, TProperty>(
this IRuleBuilder<T, TProperty?> ruleBuilder, IValidator<TProperty> validator, params string[] ruleSets)
{
var adapter = new NullableChildValidatorAdaptor<T, TProperty>(validator, validator.GetType())
{
RuleSets = ruleSets
};
return ruleBuilder.SetAsyncValidator(adapter);
}
public static IEnumerable<TSource> IsValid<TSource, TValidator>(
this IEnumerable<TSource> source, TValidator validator,
Action<List<ValidationFailure>, TSource>? handleInvalid = null)
where TValidator : IValidator<TSource>, new()
{
foreach (var s in source)
{
var result = validator.Validate(s);
if (result.IsValid)
{
yield return s;
}
else
{
handleInvalid?.Invoke(result.Errors, s);
}
}
}
}

@ -0,0 +1,24 @@
using FluentValidation;
using FluentValidation.Validators;
namespace Common.FluentValidation;
internal sealed class NullableChildValidatorAdaptor<T, TProperty> : ChildValidatorAdaptor<T, TProperty>,
IPropertyValidator<T, TProperty?>, IAsyncPropertyValidator<T, TProperty?>
{
public NullableChildValidatorAdaptor(IValidator<TProperty> validator, Type validatorType)
: base(validator, validatorType)
{
}
public override Task<bool> IsValidAsync(ValidationContext<T> context, TProperty? value,
CancellationToken cancellation)
{
return base.IsValidAsync(context, value!, cancellation);
}
public override bool IsValid(ValidationContext<T> context, TProperty? value)
{
return base.IsValid(context, value!);
}
}

@ -0,0 +1,29 @@
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;
namespace Common.YamlDotNet;
// from: https://github.com/aaubry/YamlDotNet/issues/236#issuecomment-632054372
public sealed class ReadOnlyCollectionNodeTypeResolver : INodeTypeResolver
{
public bool Resolve(NodeEvent? nodeEvent, ref Type currentType)
{
if (!currentType.IsInterface || !currentType.IsGenericType ||
!CustomGenericInterfaceImplementations.TryGetValue(currentType.GetGenericTypeDefinition(),
out var concreteType))
{
return false;
}
currentType = concreteType.MakeGenericType(currentType.GetGenericArguments());
return true;
}
private static readonly IReadOnlyDictionary<Type, Type> CustomGenericInterfaceImplementations =
new Dictionary<Type, Type>
{
{typeof(IReadOnlyCollection<>), typeof(List<>)},
{typeof(IReadOnlyList<>), typeof(List<>)},
{typeof(IReadOnlyDictionary<,>), typeof(Dictionary<,>)}
};
}

@ -18,7 +18,6 @@ using Trash.Config;
using TrashLib.Config;
using TrashLib.Config.Services;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.ObjectFactories;
@ -105,13 +104,13 @@ public class ConfigurationLoaderTest
{
new()
{
Type = ReleaseProfileType.Anime,
TrashIds = new[] {"123"},
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
Type = ReleaseProfileType.Series,
TrashIds = new[] {"456"},
StrictNegativeScores = false,
Tags = new List<string>
{

@ -2,11 +2,11 @@
- base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b
release_profiles:
- type: anime
- trash_ids: [123]
strict_negative_scores: true
tags:
- anime
- type: series
- trash_ids: [456]
tags:
- tv
- series

@ -9,6 +9,7 @@ using TrashLib.Extensions;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat;
using TrashLib.Radarr.QualityDefinition;
using TrashLib.Repo;
namespace Trash.Command;
@ -27,10 +28,11 @@ public class RadarrCommand : ServiceCommand
ILogJanitor logJanitor,
ISettingsPersister settingsPersister,
ISettingsProvider settingsProvider,
IRepoUpdater repoUpdater,
IConfigurationLoader<RadarrConfiguration> configLoader,
Func<IRadarrQualityDefinitionUpdater> qualityUpdaterFactory,
Func<ICustomFormatUpdater> customFormatUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider)
: base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater)
{
_log = log;
_configLoader = configLoader;
@ -41,7 +43,7 @@ public class RadarrCommand : ServiceCommand
public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "radarr");
public override async Task Process()
protected override async Task Process()
{
try
{

@ -12,6 +12,7 @@ using Serilog.Core;
using Serilog.Events;
using TrashLib.Config.Settings;
using TrashLib.Extensions;
using TrashLib.Repo;
using YamlDotNet.Core;
namespace Trash.Command;
@ -23,30 +24,50 @@ public abstract class ServiceCommand : ICommand, IServiceCommand
private readonly ILogJanitor _logJanitor;
private readonly ISettingsPersister _settingsPersister;
private readonly ISettingsProvider _settingsProvider;
private readonly IRepoUpdater _repoUpdater;
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description =
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `trash.yml` in the same directory as the executable.")]
public ICollection<string> Config { get; [UsedImplicitly] set; } =
new List<string> {AppPaths.DefaultConfigPath};
public abstract string CacheStoragePath { get; }
protected ServiceCommand(
ILogger log,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor,
ISettingsPersister settingsPersister,
ISettingsProvider settingsProvider)
ISettingsProvider settingsProvider,
IRepoUpdater repoUpdater)
{
_loggingLevelSwitch = loggingLevelSwitch;
_logJanitor = logJanitor;
_settingsPersister = settingsPersister;
_settingsProvider = settingsProvider;
_repoUpdater = repoUpdater;
_log = log;
}
public async ValueTask ExecuteAsync(IConsole console)
{
// Must happen first because everything can use the logger.
SetupLogging();
_loggingLevelSwitch.MinimumLevel = Debug ? LogEventLevel.Debug : LogEventLevel.Information;
// Has to happen right after logging because stuff below may use settings.
LoadSettings();
_settingsPersister.Load();
SetupHttp();
_repoUpdater.UpdateRepo();
try
{
@ -71,42 +92,10 @@ public abstract class ServiceCommand : ICommand, IServiceCommand
}
finally
{
CleanupOldLogFiles();
_logJanitor.DeleteOldestLogFiles(20);
}
}
private void LoadSettings()
{
_settingsPersister.Load();
}
[CommandOption("preview", 'p', Description =
"Only display the processed markdown results without making any API calls.")]
public bool Preview { get; [UsedImplicitly] set; } = false;
[CommandOption("debug", 'd', Description =
"Display additional logs useful for development/debug purposes.")]
public bool Debug { get; [UsedImplicitly] set; } = false;
[CommandOption("config", 'c', Description =
"One or more YAML config files to use. All configs will be used and settings are additive. " +
"If not specified, the script will look for `trash.yml` in the same directory as the executable.")]
public ICollection<string> Config { get; [UsedImplicitly] set; } =
new List<string> {AppPaths.DefaultConfigPath};
public abstract string CacheStoragePath { get; }
private void CleanupOldLogFiles()
{
_logJanitor.DeleteOldestLogFiles(20);
}
private void SetupLogging()
{
_loggingLevelSwitch.MinimumLevel =
Debug ? LogEventLevel.Debug : LogEventLevel.Information;
}
private void SetupHttp()
{
FlurlHttp.Configure(settings =>
@ -135,7 +124,7 @@ public abstract class ServiceCommand : ICommand, IServiceCommand
});
}
public abstract Task Process();
protected abstract Task Process();
protected static void ExitDueToFailure()
{

@ -5,6 +5,7 @@ using Serilog;
using Serilog.Core;
using Trash.Config;
using TrashLib.Config.Settings;
using TrashLib.Repo;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
@ -26,10 +27,11 @@ public class SonarrCommand : ServiceCommand
ILogJanitor logJanitor,
ISettingsPersister settingsPersister,
ISettingsProvider settingsProvider,
IRepoUpdater repoUpdater,
IConfigurationLoader<SonarrConfiguration> configLoader,
Func<IReleaseProfileUpdater> profileUpdaterFactory,
Func<ISonarrQualityDefinitionUpdater> qualityUpdaterFactory)
: base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider)
: base(log, loggingLevelSwitch, logJanitor, settingsPersister, settingsProvider, repoUpdater)
{
_log = log;
_configLoader = configLoader;
@ -40,7 +42,7 @@ public class SonarrCommand : ServiceCommand
public override string CacheStoragePath { get; } =
Path.Combine(AppPaths.AppDataPath, "cache", "sonarr");
public override async Task Process()
protected override async Task Process()
{
try
{

@ -16,11 +16,18 @@ sonarr:
# Quality definitions from the guide to sync to Sonarr. Choice: anime, series, hybrid
quality_definition: hybrid
# Release profiles from the guide to sync to Sonarr. Types: anime, series
# Release profiles from the guide to sync to Sonarr.
# You can optionally add tags and make negative scores strictly ignored
release_profiles:
- type: anime
- type: series
# Series
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
# Anime (Uncomment below if you want it)
# - trash_ids:
# - d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
# - 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
# Configuration specific to Radarr.
radarr:

@ -0,0 +1,30 @@
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Radarr.Config;
using TrashLib.Radarr.CustomFormat.Guide;
namespace TrashLib.Tests.Radarr.CustomFormat.Guide;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class LocalRepoCustomFormatJsonParserTest
{
[Test, AutoMockData]
public void Get_custom_format_json_works(
[Frozen] IResourcePaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
LocalRepoCustomFormatJsonParser sut)
{
paths.RepoPath.Returns("");
fileSystem.AddFile("docs/json/radarr/first.json", new MockFileData("first"));
fileSystem.AddFile("docs/json/radarr/second.json", new MockFileData("second"));
var results = sut.GetCustomFormatJson();
results.Should().BeEquivalentTo("first", "second");
}
}

@ -1,90 +0,0 @@
using FluentAssertions;
using NUnit.Framework;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class FilteredProfileDataTest
{
[Test]
public void Filter_ExcludeOptional_HasNoOptionalItems()
{
var config = new ReleaseProfileConfig();
config.Filter.IncludeOptional = false;
var profileData = new ProfileData
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"preferred1"}}
},
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>>
{
{200, new List<string> {"preferred2"}},
{100, new List<string> {"preferred3"}}
}
}
};
var filtered = new FilteredProfileData(profileData, config);
filtered.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"preferred1"}}
}
});
}
[Test]
public void Filter_IncludeOptional_HasAllOptionalItems()
{
var config = new ReleaseProfileConfig();
config.Filter.IncludeOptional = true;
var profileData = new ProfileData
{
Ignored = new List<string> {"ignored1"},
Required = new List<string> {"required1"},
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"preferred1"}}
},
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"ignored2"},
Required = new List<string> {"required2"},
Preferred = new Dictionary<int, List<string>>
{
{200, new List<string> {"preferred2"}},
{100, new List<string> {"preferred3"}}
}
}
};
var filtered = new FilteredProfileData(profileData, config);
filtered.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"ignored1", "ignored2"},
Required = new List<string> {"required1", "required2"},
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"preferred1", "preferred3"}},
{200, new List<string> {"preferred2"}}
}
});
}
}

@ -0,0 +1,52 @@
using System.IO.Abstractions.TestingHelpers;
using AutoFixture.NUnit3;
using FluentAssertions;
using Newtonsoft.Json;
using NSubstitute;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Radarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
using TrashLib.Sonarr.ReleaseProfile.Guide;
namespace TrashLib.Tests.Sonarr.ReleaseProfile.Guide;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class LocalRepoReleaseProfileJsonParserTest
{
[Test, AutoMockData]
public void Get_custom_format_json_works(
[Frozen] IResourcePaths paths,
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fileSystem,
LocalRepoReleaseProfileJsonParser sut)
{
static ReleaseProfileData MakeMockObject(string term) => new()
{
Name = "name",
TrashId = "123",
Required = new TermData[]
{
new() {Term = term}
}
};
static MockFileData MockFileData(dynamic obj) =>
new MockFileData(JsonConvert.SerializeObject(obj));
var mockData1 = MakeMockObject("first");
var mockData2 = MakeMockObject("second");
paths.RepoPath.Returns("");
fileSystem.AddFile("docs/json/sonarr/first.json", MockFileData(mockData1));
fileSystem.AddFile("docs/json/sonarr/second.json", MockFileData(mockData2));
var results = sut.GetReleaseProfileData();
results.Should().BeEquivalentTo(new[]
{
mockData1,
mockData2
});
}
}

@ -0,0 +1,194 @@
using FluentAssertions;
using NUnit.Framework;
using TestLibrary.AutoFixture;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileDataFiltererTest
{
[Test, AutoMockData]
public void Include_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"}
};
var result = sut.IncludeTerms(terms, filter);
result.Should().BeEquivalentTo(new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"}
});
}
[Test, AutoMockData]
public void Include_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
};
var result = sut.IncludeTerms(terms, filter);
result.Should().BeEquivalentTo(new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"}
}
}
});
}
[Test, AutoMockData]
public void Exclude_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"}
};
var result = sut.ExcludeTerms(terms, filter);
result.Should().BeEquivalentTo(new TermData[]
{
new() {TrashId = "3", Term = "term3"}
});
}
[Test, AutoMockData]
public void Exclude_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new PreferredTermData[]
{
new()
{
Score = 10,
Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20,
Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
};
var result = sut.ExcludeTerms(terms, filter);
result.Should().BeEquivalentTo(new PreferredTermData[]
{
new()
{
Score = 10,
Terms = new TermData[]
{
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20,
Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
});
}
[Test, AutoMockData]
public void Filter_profile_data_with_invalid_terms(ReleaseProfileDataFilterer sut)
{
var profileData = new ReleaseProfileData
{
Preferred = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"}, // excluded by filter
new() {TrashId = "2", Term = ""}, // excluded because it's invalid
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
}
};
var filter = new SonarrProfileFilterConfig
{
Exclude = new[] {"1"}
};
var result = sut.FilterProfile(profileData, filter);
result.Should().BeEquivalentTo(new ReleaseProfileData
{
Preferred = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
}
});
}
}

@ -0,0 +1,98 @@
using FluentAssertions;
using FluentValidation.TestHelper;
using NUnit.Framework;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileDataValidatorTest
{
[Test]
public void Empty_term_collections_not_allowed()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData();
validator.Validate(data).IsValid.Should().BeFalse();
}
[Test]
public void Allow_single_preferred_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = Array.Empty<TermData>(),
Ignored = Array.Empty<TermData>(),
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData()}}}
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Allow_single_required_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = new[] {new TermData {Term = "term"}},
Ignored = Array.Empty<TermData>(),
Preferred = Array.Empty<PreferredTermData>()
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Allow_single_ignored_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = Array.Empty<TermData>(),
Ignored = new[] {new TermData {Term = "term"}},
Preferred = Array.Empty<PreferredTermData>()
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Term_data_validate_empty()
{
var validator = new TermDataValidator();
var data = new TermData();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Term);
result.ShouldNotHaveValidationErrorFor(x => x.Name);
result.ShouldNotHaveValidationErrorFor(x => x.TrashId);
}
[Test]
public void Preferred_term_data_validate_empty()
{
var validator = new PreferredTermDataValidator();
var data = new PreferredTermData();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Terms);
}
}

@ -1,421 +0,0 @@
using Common;
using FluentAssertions;
using NUnit.Framework;
using Serilog;
using Serilog.Sinks.TestCorrelator;
using TestLibrary;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileParserTest
{
[OneTimeSetUp]
public void Setup()
{
// Formatter.AddFormatter(new ProfileDataValueFormatter());
}
private class Context
{
public Context()
{
var logger = new LoggerConfiguration()
.WriteTo.TestCorrelator()
.MinimumLevel.Debug()
.CreateLogger();
Config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig()}
};
GuideParser = new ReleaseProfileGuideParser(logger);
}
public SonarrConfiguration Config { get; }
public ReleaseProfileGuideParser GuideParser { get; }
public ResourceDataReader TestData { get; } = new(typeof(ReleaseProfileParserTest), "Data");
public IDictionary<string, ProfileData> ParseWithDefaults(string markdown)
{
return GuideParser.ParseMarkdown(Config.ReleaseProfiles.First(), markdown);
}
}
[Test]
public void Parse_CodeBlockScopedCategories_CategoriesSwitch()
{
var markdown = StringUtils.TrimmedString(@"
# Test Release Profile
Add this to must not contain (ignored)
```
abc
```
Add this to must contain (required)
```
xyz
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
results.Should().ContainKey("Test Release Profile")
.WhoseValue.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"abc"},
Required = new List<string> {"xyz"}
});
}
[Test]
public void Parse_HeaderCategoryFollowedByCodeBlockCategories_CodeBlockChangesCurrentCategory()
{
var markdown = StringUtils.TrimmedString(@"
# Test Release Profile
## Must Not Contain
Add this one
```
abc
```
Add this to must contain (required)
```
xyz
```
One more
```
123
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
results.Should().ContainKey("Test Release Profile")
.WhoseValue.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"abc"},
Required = new List<string> {"xyz", "123"}
});
}
[Test]
public void Parse_IgnoredRequiredPreferredScores()
{
var context = new Context();
var markdown = context.TestData.ReadData("test_parse_markdown_complete_doc.md");
var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown);
results.Count.Should().Be(1);
var profile = results.First().Value;
profile.Ignored.Should().BeEquivalentTo("term2", "term3");
profile.Required.Should().BeEquivalentTo("term4");
profile.Preferred.Should().ContainKey(100).WhoseValue.Should().BeEquivalentTo(new List<string> {"term1"});
}
[Test]
public void Parse_IncludePreferredWhenRenaming()
{
var context = new Context();
var markdown = context.TestData.ReadData("include_preferred_when_renaming.md");
var results = context.ParseWithDefaults(markdown);
results.Should()
.ContainKey("First Release Profile")
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(true);
results.Should()
.ContainKey("Second Release Profile")
.WhoseValue.IncludePreferredWhenRenaming.Should().Be(false);
}
[Test]
public void Parse_IndentedIncludePreferred_ShouldBeParsed()
{
var markdown = StringUtils.TrimmedString(@"
# Release Profile 1
!!! Warning
Do not check include preferred
must contain
```
test1
```
# Release Profile 2
!!! Warning
Check include preferred
must contain
```
test2
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData>
{
{
"Release Profile 1", new ProfileData
{
IncludePreferredWhenRenaming = false,
Required = new List<string> {"test1"}
}
},
{
"Release Profile 2", new ProfileData
{
IncludePreferredWhenRenaming = true,
Required = new List<string> {"test2"}
}
}
};
results.Should().BeEquivalentTo(expectedResults);
}
[Test]
public void Parse_OptionalTerms_AreCapturedProperly()
{
var markdown = StringUtils.TrimmedString(@"
# Optional Release Profile
```
skipped1
```
## Must Not Contain
```
optional1
```
## Preferred
score [10]
```
optional2
```
One more must contain:
```
optional3
```
# Second Release Profile
This must not contain:
```
not-optional1
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData>
{
{
"Optional Release Profile", new ProfileData
{
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"optional1"},
Required = new List<string> {"optional3"},
Preferred = new Dictionary<int, List<string>>
{
{10, new List<string> {"optional2"}}
}
}
}
},
{
"Second Release Profile", new ProfileData
{
Ignored = new List<string> {"not-optional1"}
}
}
};
results.Should().BeEquivalentTo(expectedResults);
}
[Test]
public void Parse_PotentialScore_WarningLogged()
{
var markdown = StringUtils.TrimmedString(@"
# First Release Profile
The below line should be a score but isn't because it's missing the word 'score'.
Use this number [100]
```
abc
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
results.Should().BeEmpty();
const string expectedLog =
"Found a potential score on line #5 that will be ignored because the " +
"word 'score' is missing (This is probably a bug in the guide itself): \"[100]\"";
TestCorrelator.GetLogEventsFromCurrentContext()
.Should().ContainSingle(evt => evt.RenderMessage(default) == expectedLog);
}
[Test]
public void Parse_ScoreWithoutCategory_ImplicitlyPreferred()
{
var markdown = StringUtils.TrimmedString(@"
# Test Release Profile
score is [100]
```
abc
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
results.Should()
.ContainKey("Test Release Profile")
.WhoseValue.Preferred.Should()
.BeEquivalentTo(new Dictionary<int, List<string>>
{
{100, new List<string> {"abc"}}
});
}
[Test]
public void Parse_SkippableLines_AreSkippedWithLog()
{
var markdown = StringUtils.TrimmedString(@"
# First Release Profile
!!! Admonition lines are skipped
Indented lines are skipped
");
// List of substrings of logs that should appear in the resulting list of logs after parsing is done.
// We are only looking for logs relevant to the skipped lines we're testing for.
var expectedLogs = new List<string>
{
"Skip Admonition",
"Skip Indented Line"
};
var context = new Context();
var results = context.ParseWithDefaults(markdown);
results.Should().BeEmpty();
var ctx = TestCorrelator.GetLogEventsFromCurrentContext().ToList();
foreach (var log in expectedLogs)
{
ctx.Should().Contain(evt => evt.MessageTemplate.Text.Contains(log));
}
}
[Test]
public void Parse_StrictNegativeScores()
{
var context = new Context();
context.Config.ReleaseProfiles = new List<ReleaseProfileConfig>
{
new() {StrictNegativeScores = true}
};
var markdown = context.TestData.ReadData("strict_negative_scores.md");
var results = context.ParseWithDefaults(markdown);
results.Should()
.ContainKey("Test Release Profile").WhoseValue.Should()
.BeEquivalentTo(new
{
Required = new { },
Ignored = new List<string> {"abc"},
Preferred = new Dictionary<int, List<string>> {{0, new List<string> {"xyz"}}}
});
}
[Test]
public void Parse_TermsWithoutCategory_AreSkipped()
{
var markdown = StringUtils.TrimmedString(@"
# Test Release Profile
```
skipped1
```
## Must Not Contain
```
added1
```
## Preferred
score [10]
```
added2
```
One more
```
added3
```
# Second Release Profile
```
skipped2
```
");
var context = new Context();
var results = context.ParseWithDefaults(markdown);
var expectedResults = new Dictionary<string, ProfileData>
{
{
"Test Release Profile", new ProfileData
{
Ignored = new List<string> {"added1"},
Preferred = new Dictionary<int, List<string>>
{
{10, new List<string> {"added2", "added3"}}
}
}
}
};
results.Should().BeEquivalentTo(expectedResults);
}
}

@ -1,148 +0,0 @@
using FluentAssertions;
using NUnit.Framework;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr.ReleaseProfile;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class UtilsTest
{
private static readonly SonarrProfileFilterConfig _filterIncludeOptional = new() {IncludeOptional = true};
private static readonly SonarrProfileFilterConfig _filterExcludeOptional = new() {IncludeOptional = false};
[Test]
public void Profile_with_only_ignored_should_not_be_filtered_out()
{
var profileData = new ProfileData {Ignored = new List<string> {"term"}};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Profile_with_only_required_should_not_be_filtered_out()
{
var profileData = new ProfileData {Required = new List<string> {"term"}};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Profile_with_only_preferred_should_not_be_filtered_out()
{
var profileData = new ProfileData
{
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"term"}}
}
};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Profile_with_only_optional_ignored_should_not_be_filtered_out()
{
var profileData = new ProfileData
{
Optional = new ProfileDataOptional
{
Ignored = new List<string> {"term"}
}
};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Profile_with_only_optional_required_should_not_be_filtered_out()
{
var profileData = new ProfileData
{
Optional = new ProfileDataOptional
{
Required = new List<string> {"required1"}
}
};
var data = new Dictionary<string, ProfileData>
{
{"actualData", profileData}
};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Profile_with_only_optional_preferred_should_not_be_filtered_out()
{
var profileData = new ProfileData
{
Optional = new ProfileDataOptional
{
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"term"}}
}
}
};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().BeEquivalentTo(data);
}
[Test]
public void Empty_profiles_should_be_filtered_out()
{
var data = new Dictionary<string, ProfileData>
{
{"emptyData", new ProfileData()}
};
var filteredData = Utils.FilterProfiles(data, _filterIncludeOptional);
filteredData.Should().NotContainKey("emptyData");
}
[Test]
public void Profile_with_only_optionals_should_be_filtered_out_when_config_excludes_optionals()
{
var profileData = new ProfileData
{
Optional = new ProfileDataOptional
{
Preferred = new Dictionary<int, List<string>>
{
{100, new List<string> {"term"}}
}
}
};
var data = new Dictionary<string, ProfileData> {{"actualData", profileData}};
var filteredData = Utils.FilterProfiles(data, _filterExcludeOptional);
filteredData.Should().BeEmpty();
}
}

@ -1,50 +0,0 @@
using NSubstitute;
using NUnit.Framework;
using Serilog;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
{
private class Context
{
public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>();
public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>();
public ILogger Logger { get; } = Substitute.For<ILogger>();
public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
}
[Test]
public void ProcessReleaseProfile_InvalidReleaseProfiles_NoCrashNoCalls()
{
var context = new Context();
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, new SonarrConfiguration());
context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
}
[Test]
public void ProcessReleaseProfile_SingleProfilePreview()
{
var context = new Context();
context.Parser.GetMarkdownData(ReleaseProfileType.Anime).Returns("theMarkdown");
var config = new SonarrConfiguration
{
ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}}
};
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, config);
context.Parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown");
}
}

@ -5,7 +5,6 @@ using NUnit.Framework;
using TrashLib.Config;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Tests.Sonarr;
@ -28,21 +27,27 @@ public class SonarrConfigurationTest
public void Validation_fails_for_all_missing_required_properties()
{
// default construct which should yield default values (invalid) for all required properties
var config = new SonarrConfiguration();
var config = new SonarrConfiguration
{
// validation is only applied to actual release profile elements. Not if it's empty.
ReleaseProfiles = new[] {new ReleaseProfileConfig()}
};
var validator = _container.Resolve<IValidator<SonarrConfiguration>>();
var result = validator.Validate(config);
var messages = new SonarrValidationMessages();
var expectedErrorMessageSubstrings = new[]
{
"Property 'base_url' is required",
"Property 'api_key' is required",
"'type' is required for 'release_profiles' elements"
messages.ApiKey,
messages.BaseUrl,
messages.ReleaseProfileTrashIds
};
result.IsValid.Should().BeFalse();
result.Errors.Select(e => e.ErrorMessage).Should()
.OnlyContain(x => expectedErrorMessageSubstrings.Any(x.Contains));
result.Errors.Select(e => e.ErrorMessage)
.Should().BeEquivalentTo(expectedErrorMessageSubstrings);
}
[Test]
@ -54,7 +59,7 @@ public class SonarrConfigurationTest
BaseUrl = "required value",
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new() {Type = ReleaseProfileType.Anime}
new() {TrashIds = new[] {"123"}}
}
};

@ -18,6 +18,7 @@ public class YamlSerializerFactory : IYamlSerializerFactory
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithNodeTypeResolver(new ReadOnlyCollectionNodeTypeResolver())
.WithObjectFactory(_objectFactory)
.Build();
}

@ -13,14 +13,14 @@ public static class FlurlLogging
settings.BeforeCall = call =>
{
var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Request to {Url}", url);
log.Debug("HTTP Request: {Method} {Url}", call.HttpRequestMessage.Method, url);
};
settings.AfterCall = call =>
{
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)";
var url = urlInterceptor(call.Request.Url.Clone());
log.Debug("HTTP Response {Status} from {Url}", statusCode, url);
log.Debug("HTTP Response: {Status} {Method} {Url}", statusCode, call.HttpRequestMessage.Method, url);
};
settings.OnRedirect = call =>

@ -1,4 +1,4 @@
using Common.Extensions;
using Common.FluentValidation;
using FluentValidation;
using JetBrains.Annotations;

@ -1,26 +1,24 @@
using System.IO.Abstractions;
using TrashLib.Repo;
using TrashLib.Radarr.Config;
namespace TrashLib.Radarr.CustomFormat.Guide;
internal class LocalRepoCustomFormatJsonParser : IRadarrGuideService
public class LocalRepoCustomFormatJsonParser : IRadarrGuideService
{
private readonly IFileSystem _fileSystem;
private readonly IRepoUpdater _repoUpdater;
private readonly IResourcePaths _paths;
public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IRepoUpdater repoUpdater)
public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IResourcePaths paths)
{
_fileSystem = fileSystem;
_repoUpdater = repoUpdater;
_paths = paths;
}
public IEnumerable<string> GetCustomFormatJson()
{
_repoUpdater.UpdateRepo();
var jsonDir = Path.Combine(_repoUpdater.RepoPath, "docs/json/radarr");
var jsonDir = Path.Combine(_paths.RepoPath, "docs/json/radarr");
var tasks = _fileSystem.Directory.GetFiles(jsonDir, "*.json")
.Select(async f => await _fileSystem.File.ReadAllTextAsync(f));
.Select(f => _fileSystem.File.ReadAllTextAsync(f));
return Task.WhenAll(tasks).Result;
}

@ -1,4 +1,3 @@
using Common;
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Radarr.Config;

@ -1,4 +1,3 @@
using Common;
using Common.Extensions;
using Newtonsoft.Json.Linq;
using TrashLib.Radarr.CustomFormat.Api;

@ -9,6 +9,7 @@ public interface ISonarrApi
Task<IList<SonarrReleaseProfile>> GetReleaseProfiles();
Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate);
Task<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile newProfile);
Task DeleteReleaseProfile(int releaseProfileId);
Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition();
Task<IList<SonarrQualityDefinitionItem>> UpdateQualityDefinition(

@ -68,6 +68,13 @@ public class SonarrApi : ISonarrApi
return _profileHandler.CompatibleReleaseProfileForReceiving(response);
}
public async Task DeleteReleaseProfile(int releaseProfileId)
{
await BaseUrl()
.AppendPathSegment($"releaseprofile/{releaseProfileId}")
.DeleteAsync();
}
public async Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition()
{
return await BaseUrl()

@ -4,5 +4,5 @@ public interface ISonarrValidationMessages
{
string BaseUrl { get; }
string ApiKey { get; }
string ReleaseProfileType { get; }
string ReleaseProfileTrashIds { get; }
}

@ -1,29 +1,24 @@
using TrashLib.Config.Services;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
namespace TrashLib.Sonarr.Config;
public class SonarrConfiguration : ServiceConfiguration
{
public IList<ReleaseProfileConfig> ReleaseProfiles { get; set; } = new List<ReleaseProfileConfig>();
public IList<ReleaseProfileConfig> ReleaseProfiles { get; init; } = Array.Empty<ReleaseProfileConfig>();
public SonarrQualityDefinitionType? QualityDefinition { get; init; }
}
public class ReleaseProfileConfig
{
// -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 which will make using the property
// frustrating.
public ReleaseProfileType Type { get; init; } = (ReleaseProfileType) (-1);
public IReadOnlyCollection<string> TrashIds { get; init; } = Array.Empty<string>();
public bool StrictNegativeScores { get; init; }
public SonarrProfileFilterConfig Filter { get; init; } = new();
public ICollection<string> Tags { get; init; } = new List<string>();
public IReadOnlyCollection<string> Tags { get; init; } = Array.Empty<string>();
public SonarrProfileFilterConfig? Filter { get; init; }
}
public class SonarrProfileFilterConfig
{
public bool IncludeOptional { get; set; }
// todo: Add Include & Exclude later (list of strings)
public IReadOnlyCollection<string> Include { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Exclude { get; init; } = Array.Empty<string>();
}

@ -21,6 +21,6 @@ internal class ReleaseProfileConfigValidator : AbstractValidator<ReleaseProfileC
{
public ReleaseProfileConfigValidator(ISonarrValidationMessages messages)
{
RuleFor(x => x.Type).IsInEnum().WithMessage(messages.ReleaseProfileType);
RuleFor(x => x.TrashIds).NotEmpty().WithMessage(messages.ReleaseProfileTrashIds);
}
}

@ -11,6 +11,6 @@ internal class SonarrValidationMessages : ISonarrValidationMessages
public string ApiKey =>
"Property 'api_key' is required";
public string ReleaseProfileType =>
"'type' is required for 'release_profiles' elements";
public string ReleaseProfileTrashIds =>
"'trash_ids' is required for 'release_profiles' elements";
}

@ -1,32 +0,0 @@
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile;
public class FilteredProfileData
{
private readonly ReleaseProfileConfig _config;
private readonly ProfileData _profileData;
public FilteredProfileData(ProfileData profileData, ReleaseProfileConfig config)
{
_profileData = profileData;
_config = config;
}
public IEnumerable<string> Required => _config.Filter.IncludeOptional
? _profileData.Required.Concat(_profileData.Optional.Required).ToList()
: _profileData.Required;
public IEnumerable<string> Ignored => _config.Filter.IncludeOptional
? _profileData.Ignored.Concat(_profileData.Optional.Ignored).ToList()
: _profileData.Ignored;
public IDictionary<int, List<string>> Preferred => _config.Filter.IncludeOptional
? _profileData.Preferred
.Union(_profileData.Optional.Preferred)
.GroupBy(kvp => kvp.Key)
.ToDictionary(grp => grp.Key, grp => new List<string>(grp.SelectMany(l => l.Value)))
: _profileData.Preferred;
public bool? IncludePreferredWhenRenaming => _profileData.IncludePreferredWhenRenaming;
}

@ -0,0 +1,6 @@
namespace TrashLib.Sonarr.ReleaseProfile.Guide;
public interface ISonarrGuideService
{
IReadOnlyCollection<ReleaseProfileData> GetReleaseProfileData();
}

@ -0,0 +1,36 @@
using System.IO.Abstractions;
using Common.FluentValidation;
using MoreLinq;
using Newtonsoft.Json;
using TrashLib.Radarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile.Guide;
public class LocalRepoReleaseProfileJsonParser : ISonarrGuideService
{
private readonly IFileSystem _fileSystem;
private readonly IResourcePaths _paths;
public LocalRepoReleaseProfileJsonParser(IFileSystem fileSystem, IResourcePaths paths)
{
_fileSystem = fileSystem;
_paths = paths;
}
public IReadOnlyCollection<ReleaseProfileData> GetReleaseProfileData()
{
var converter = new TermDataConverter();
var jsonDir = Path.Combine(_paths.RepoPath, "docs/json/sonarr");
var tasks = _fileSystem.Directory.GetFiles(jsonDir, "*.json")
.Select(async f =>
{
var json = await _fileSystem.File.ReadAllTextAsync(f);
return JsonConvert.DeserializeObject<ReleaseProfileData>(json, converter);
});
return Task.WhenAll(tasks).Result
.Choose(x => x is not null ? (true, x) : default) // Make non-nullable type
.IsValid(new ReleaseProfileDataValidator())
.ToList();
}
}

@ -1,9 +0,0 @@
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile;
public interface IReleaseProfileGuideParser
{
Task<string> GetMarkdownData(ReleaseProfileType profileName);
IDictionary<string, ProfileData> ParseMarkdown(ReleaseProfileConfig config, string markdown);
}

@ -1,77 +0,0 @@
using Common.Extensions;
using Serilog;
namespace TrashLib.Sonarr.ReleaseProfile;
public enum TermCategory
{
Required,
Ignored,
Preferred
}
public class ParserState
{
public ParserState(ILogger logger)
{
Log = logger;
ResetParserState();
}
private ILogger Log { get; }
public string? ProfileName { get; set; }
public int? Score { get; set; }
public ScopedState<TermCategory?> CurrentCategory { get; } = new();
public bool InsideCodeBlock { get; set; }
public int ProfileHeaderDepth { get; set; }
public int CurrentHeaderDepth { get; set; }
public int LineNumber { get; set; }
public IDictionary<string, ProfileData> Results { get; } = new Dictionary<string, ProfileData>();
// If null, then terms are not considered optional
public ScopedState<bool> TermsAreOptional { get; } = new();
public bool IsValid => ProfileName != null && CurrentCategory.Value != null &&
// If category is preferred, we also require a score
(CurrentCategory.Value != TermCategory.Preferred || Score != null);
public ICollection<string> IgnoredTerms
=> TermsAreOptional.Value ? GetProfile().Optional.Ignored : GetProfile().Ignored;
public ICollection<string> RequiredTerms
=> TermsAreOptional.Value ? GetProfile().Optional.Required : GetProfile().Required;
public IDictionary<int, List<string>> PreferredTerms
=> TermsAreOptional.Value ? GetProfile().Optional.Preferred : GetProfile().Preferred;
public ProfileData GetProfile()
{
if (ProfileName == null)
{
throw new NullReferenceException();
}
return Results.GetOrCreate(ProfileName);
}
public void ResetParserState()
{
ProfileName = null;
Score = null;
InsideCodeBlock = false;
ProfileHeaderDepth = -1;
}
public void ResetScopeState(int scope)
{
if (CurrentCategory.Reset(scope))
{
Log.Debug(" - Reset Category State for Scope: {Scope}", scope);
}
if (TermsAreOptional.Reset(scope))
{
Log.Debug(" - Reset Optional State for Scope: {Scope}", scope);
}
}
}

@ -1,23 +0,0 @@
namespace TrashLib.Sonarr.ReleaseProfile;
public record ProfileDataOptional
{
public ICollection<string> Required { get; init; } = new List<string>();
public ICollection<string> Ignored { get; init; } = new List<string>();
public IDictionary<int, List<string>> Preferred { get; init; } = new Dictionary<int, List<string>>();
}
public record ProfileData
{
public ICollection<string> Required { get; init; } = new List<string>();
public ICollection<string> Ignored { get; init; } = new List<string>();
public IDictionary<int, List<string>> Preferred { get; init; } = new Dictionary<int, List<string>>();
// We use 'null' here to represent no explicit mention of the "include preferred" string
// found in the markdown. We use this to control whether or not the corresponding profile
// section gets printed in the first place, or if we modify the existing setting for
// existing profiles on the server.
public bool? IncludePreferredWhenRenaming { get; set; }
public ProfileDataOptional Optional { get; init; } = new();
}

@ -0,0 +1,60 @@
using JetBrains.Annotations;
using Newtonsoft.Json;
namespace TrashLib.Sonarr.ReleaseProfile;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record TermData
{
[JsonProperty("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Term { get; init; } = string.Empty;
public override string ToString()
{
return $"[TrashId: {TrashId}] [Name: {Name}] [Term: {Term}]";
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record PreferredTermData
{
public int Score { get; init; }
public IReadOnlyCollection<TermData> Terms { get; init; } = Array.Empty<TermData>();
public void Deconstruct(out int score, out IReadOnlyCollection<TermData> terms)
{
score = Score;
terms = Terms;
}
public override string ToString()
{
return $"[Score: {Score}] [Terms: {Terms.Count}]";
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileData
{
[JsonProperty("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public bool IncludePreferredWhenRenaming { get; init; }
public IReadOnlyCollection<TermData> Required { get; init; } = Array.Empty<TermData>();
public IReadOnlyCollection<TermData> Ignored { get; init; } = Array.Empty<TermData>();
public IReadOnlyCollection<PreferredTermData> Preferred { get; init; } = Array.Empty<PreferredTermData>();
public override string ToString()
{
return $"[TrashId: {TrashId}] " +
$"[Name: {Name}] " +
$"[IncludePreferred: {IncludePreferredWhenRenaming}] " +
$"[Required: {Required.Count}] " +
$"[Ignored: {Ignored.Count}] " +
$"[Preferred: {Preferred.Count}]";
}
}

@ -0,0 +1,104 @@
using System.Collections.ObjectModel;
using Common.FluentValidation;
using FluentValidation.Results;
using Serilog;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile;
public class ReleaseProfileDataFilterer
{
private readonly ILogger _log;
public ReleaseProfileDataFilterer(ILogger log)
{
_log = log;
}
private void LogInvalidTerm(List<ValidationFailure> failures, string filterDescription)
{
_log.Debug("Validation failed on term data ({Filter}): {Failures}", filterDescription, failures);
}
public ReadOnlyCollection<TermData> ExcludeTerms(IEnumerable<TermData> terms,
IEnumerable<string> includeFilter)
{
return terms
.ExceptBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase)
.IsValid(new TermDataValidator(), (e, x) => LogInvalidTerm(e, $"Exclude: {x}"))
.ToList().AsReadOnly();
}
public ReadOnlyCollection<PreferredTermData> ExcludeTerms(IEnumerable<PreferredTermData> terms,
IReadOnlyCollection<string> includeFilter)
{
return terms
.Select(x => new PreferredTermData
{
Score = x.Score,
Terms = ExcludeTerms(x.Terms, includeFilter)
})
.IsValid(new PreferredTermDataValidator(), (e, x) => LogInvalidTerm(e, $"Exclude Preferred: {x}"))
.ToList()
.AsReadOnly();
}
public ReadOnlyCollection<TermData> IncludeTerms(IEnumerable<TermData> terms,
IEnumerable<string> includeFilter)
{
return terms
.IntersectBy(includeFilter, x => x.TrashId, StringComparer.InvariantCultureIgnoreCase)
.IsValid(new TermDataValidator(),
(e, x) => LogInvalidTerm(e, $"Include: {x}"))
.ToList().AsReadOnly();
}
public ReadOnlyCollection<PreferredTermData> IncludeTerms(IEnumerable<PreferredTermData> terms,
IReadOnlyCollection<string> includeFilter)
{
return terms
.Select(x => new PreferredTermData
{
Score = x.Score,
Terms = IncludeTerms(x.Terms, includeFilter)
})
.IsValid(new PreferredTermDataValidator(), (e, x) => LogInvalidTerm(e, $"Include Preferred {x}"))
.ToList()
.AsReadOnly();
}
public ReleaseProfileData? FilterProfile(ReleaseProfileData selectedProfile,
SonarrProfileFilterConfig profileFilter)
{
if (profileFilter.Include.Any())
{
_log.Debug("Using inclusion filter");
return new ReleaseProfileData
{
TrashId = selectedProfile.TrashId,
Name = selectedProfile.Name,
IncludePreferredWhenRenaming = selectedProfile.IncludePreferredWhenRenaming,
Required = IncludeTerms(selectedProfile.Required, profileFilter.Include),
Ignored = IncludeTerms(selectedProfile.Ignored, profileFilter.Include),
Preferred = IncludeTerms(selectedProfile.Preferred, profileFilter.Include)
};
}
if (profileFilter.Exclude.Any())
{
_log.Debug("Using exclusion filter");
return new ReleaseProfileData
{
TrashId = selectedProfile.TrashId,
Name = selectedProfile.Name,
IncludePreferredWhenRenaming = selectedProfile.IncludePreferredWhenRenaming,
Required = ExcludeTerms(selectedProfile.Required, profileFilter.Exclude),
Ignored = ExcludeTerms(selectedProfile.Ignored, profileFilter.Exclude),
Preferred = ExcludeTerms(selectedProfile.Preferred, profileFilter.Exclude)
};
}
_log.Debug("Filter property present but is empty");
return null;
}
}

@ -0,0 +1,31 @@
using FluentValidation;
namespace TrashLib.Sonarr.ReleaseProfile;
internal class TermDataValidator : AbstractValidator<TermData>
{
public TermDataValidator()
{
RuleFor(x => x.Term).NotEmpty();
}
}
internal class PreferredTermDataValidator : AbstractValidator<PreferredTermData>
{
public PreferredTermDataValidator()
{
RuleFor(x => x.Terms).NotEmpty();
}
}
internal class ReleaseProfileDataValidator : AbstractValidator<ReleaseProfileData>
{
public ReleaseProfileDataValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.TrashId).NotEmpty();
RuleFor(x => x)
.Must(x => x.Required.Any() || x.Ignored.Any() || x.Preferred.Any())
.WithMessage("Must have at least one of Required, Ignored, or Preferred terms");
}
}

@ -1,328 +0,0 @@
using System.Text.RegularExpressions;
using Common.Extensions;
using Flurl;
using Flurl.Http;
using Serilog;
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile;
internal class ReleaseProfileGuideParser : IReleaseProfileGuideParser
{
private readonly Dictionary<ReleaseProfileType, string> _markdownDocNames = new()
{
{ReleaseProfileType.Anime, "Sonarr-Release-Profile-RegEx-Anime"},
{ReleaseProfileType.Series, "Sonarr-Release-Profile-RegEx"}
};
private readonly (TermCategory, Regex)[] _regexCategories =
{
(TermCategory.Required, BuildRegex(@"must contain")),
(TermCategory.Ignored, BuildRegex(@"must not contain")),
(TermCategory.Preferred, BuildRegex(@"preferred"))
};
private readonly Regex _regexHeader = new(@"^(#+)\s(.+?)\s*$", RegexOptions.Compiled);
private readonly Regex _regexHeaderReleaseProfile = BuildRegex(@"release profile");
private readonly Regex _regexPotentialScore = BuildRegex(@"\[(-?[\d]+)\]");
private readonly Regex _regexScore = BuildRegex(@"score.*?\[(-?[\d]+)\]");
public ReleaseProfileGuideParser(ILogger logger)
{
Log = logger;
}
private ILogger Log { get; }
public async Task<string> GetMarkdownData(ReleaseProfileType profileName)
{
return await BuildUrl(profileName).GetStringAsync();
}
public IDictionary<string, ProfileData> ParseMarkdown(ReleaseProfileConfig config, string markdown)
{
var state = new ParserState(Log);
var reader = new StringReader(markdown);
for (var line = reader.ReadLine(); line != null; line = reader.ReadLine())
{
state.LineNumber++;
if (string.IsNullOrEmpty(line))
{
continue;
}
// Always check if we're starting a fenced code block. Whether we are inside one or not greatly affects
// the logic we use.
if (line.StartsWith("```"))
{
state.InsideCodeBlock = !state.InsideCodeBlock;
continue;
}
// Not inside brackets
if (!state.InsideCodeBlock)
{
OutsideFence_ParseMarkdown(line, state);
}
// Inside brackets
else
{
if (!state.IsValid)
{
Log.Debug(" - !! Inside bracket with invalid state; skipping! " +
"[Profile Name: {ProfileName}] " +
"[Category: {Category}] " + "[Score: {Score}] " + "[Line: {Line}] ",
state.ProfileName,
state.CurrentCategory.Value, state.Score, line);
}
else
{
InsideFence_ParseMarkdown(config, line, state);
}
}
}
Log.Debug("\n");
return state.Results;
}
private bool IsSkippableLine(string line)
{
// Skip lines with leading whitespace (i.e. indentation).
// These lines will almost always be `!!! attention` blocks of some kind and won't contain useful data.
if (char.IsWhiteSpace(line, 0))
{
Log.Debug(" - Skip Indented Line: {Line}", line);
return true;
}
// Lines that begin with `???` or `!!!` are admonition syntax (extended markdown supported by Python)
if (line.StartsWith("!!!") || line.StartsWith("???"))
{
Log.Debug(" - Skip Admonition: {Line}", line);
return true;
}
return false;
}
private static Regex BuildRegex(string regex)
{
return new Regex(regex, RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
private Url BuildUrl(ReleaseProfileType profileName)
{
return "https://raw.githubusercontent.com/TRaSH-/Guides/master/docs/Sonarr".AppendPathSegment(
$"{_markdownDocNames[profileName]}.md");
}
private void InsideFence_ParseMarkdown(ReleaseProfileConfig config, string line, ParserState state)
{
// Sometimes a comma is present at the end of these lines, because when it's
// pasted into Sonarr it acts as a delimiter. However, when using them with the
// API we do not need them.
line = line.TrimEnd(',');
var category = state.CurrentCategory.Value;
switch (category!.Value)
{
case TermCategory.Preferred:
{
Log.Debug(" + Capture Term " +
"[Category: {CurrentCategory}] " +
"[Optional: {Optional}] " +
"[Score: {Score}] " +
"[Strict: {StrictNegativeScores}] " +
"[Term: {Line}]",
category.Value, state.TermsAreOptional.Value, state.Score, config.StrictNegativeScores, line);
if (config.StrictNegativeScores && state.Score < 0)
{
state.IgnoredTerms.Add(line);
}
else
{
// Score is already checked for null prior to the method being invoked.
var prefList = state.PreferredTerms.GetOrCreate(state.Score!.Value);
prefList.Add(line);
}
break;
}
case TermCategory.Ignored:
{
state.IgnoredTerms.Add(line);
Log.Debug(" + Capture Term " +
"[Category: {Category}] " +
"[Optional: {Optional}] " +
"[Term: {Line}]",
category.Value, state.TermsAreOptional.Value, line);
break;
}
case TermCategory.Required:
{
state.RequiredTerms.Add(line);
Log.Debug(" + Capture Term " +
"[Category: {Category}] " +
"[Optional: {Optional}] " +
"[Term: {Line}]",
category.Value, state.TermsAreOptional.Value, line);
break;
}
default:
{
throw new ArgumentOutOfRangeException($"Unknown term category: {category.Value}");
}
}
}
private void OutsideFence_ParseMarkdown(string line, ParserState state)
{
// ReSharper disable once InlineOutVariableDeclaration
Match match;
// Header Processing. Never do any additional processing to headers, so return after processing it
if (_regexHeader.Match(line, out match))
{
OutsideFence_ParseHeader(state, match);
return;
}
// Until we find a header that defines a profile, we don't care about anything under it.
if (string.IsNullOrEmpty(state.ProfileName))
{
return;
}
// These are often found in admonition (indented) blocks, so we check for it before we
// run the IsSkippableLine() check.
if (line.ContainsIgnoreCase("include preferred"))
{
state.GetProfile().IncludePreferredWhenRenaming = !line.ContainsIgnoreCase("not");
Log.Debug(" - 'Include Preferred' found [Value: {IncludePreferredWhenRenaming}] [Line: {Line}]",
state.GetProfile().IncludePreferredWhenRenaming, line);
return;
}
if (IsSkippableLine(line))
{
return;
}
OutsideFence_ParseInformationOnSameLine(line, state);
}
private void OutsideFence_ParseHeader(ParserState state, Match match)
{
var headerDepth = match.Groups[1].Length;
var headerText = match.Groups[2].Value;
state.CurrentHeaderDepth = headerDepth;
// Always reset the scope-based state any time we see a header, regardless of depth or phrasing.
// Each header "resets" scope-based state, even if it's entering into a nested header, which usually will
// not reset as much state.
state.ResetScopeState(headerDepth);
Log.Debug("> Parsing Header [Nested: {Nested}] [Depth: {HeaderDepth}] [Text: {HeaderText}]",
headerDepth > state.ProfileHeaderDepth, headerDepth, headerText);
// Profile name (always reset previous state here)
if (_regexHeaderReleaseProfile.Match(headerText).Success)
{
state.ResetParserState();
state.ProfileName = headerText;
state.ProfileHeaderDepth = headerDepth;
Log.Debug(" - New Profile [Text: {HeaderText}]", headerText);
}
else if (headerDepth <= state.ProfileHeaderDepth)
{
Log.Debug(" - !! Non-nested, non-profile header found; resetting all state");
state.ResetParserState();
}
// If a single header can be parsed with multiple phrases, add more if conditions below this comment.
// In order to make sure all checks happen as needed, do not return from the condition (to allow conditions
// below it to be executed)
// Another note: Any "state" set by headers has longer lasting effects. That state will remain in effect
// until the next header. That means multiple fenced code blocks will be impacted.
ParseAndSetOptional(headerText, state);
ParseAndSetCategory(headerText, state);
}
private void OutsideFence_ParseInformationOnSameLine(string line, ParserState state)
{
// ReSharper disable once InlineOutVariableDeclaration
Match match;
ParseAndSetOptional(line, state);
ParseAndSetCategory(line, state);
if (_regexScore.Match(line, out match))
{
// As a convenience, if we find a score, we obviously should set the category to Preferred even if
// the guide didn't explicitly mention that.
state.CurrentCategory.PushValue(TermCategory.Preferred, state.CurrentHeaderDepth);
state.Score = int.Parse(match.Groups[1].Value);
Log.Debug(" - Score [Value: {Score}]", state.Score);
}
else if (_regexPotentialScore.Match(line, out match))
{
Log.Warning("Found a potential score on line #{Line} that will be ignored because the " +
"word 'score' is missing (This is probably a bug in the guide itself): {ScoreMatch}",
state.LineNumber, match.Groups[0].Value);
}
}
private void ParseAndSetCategory(string line, ParserState state)
{
var category = ParseCategory(line);
if (category == null)
{
return;
}
state.CurrentCategory.PushValue(category.Value, state.CurrentHeaderDepth);
Log.Debug(" - Category Set " +
"[Scope: {Scope}] " +
"[Name: {Category}] " +
"[Stack Size: {StackSize}] " +
"[Line: {Line}]",
category.Value, state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line);
}
private void ParseAndSetOptional(string line, ParserState state)
{
if (line.ContainsIgnoreCase("optional"))
{
state.TermsAreOptional.PushValue(true, state.CurrentHeaderDepth);
Log.Debug(" - Optional Set " +
"[Scope: {Scope}] " +
"[Stack Size: {StackSize}] " +
"[Line: {Line}]",
state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line);
}
}
private TermCategory? ParseCategory(string line)
{
foreach (var (category, regex) in _regexCategories)
{
if (regex.Match(line).Success)
{
return category;
}
}
return null;
}
}

@ -1,7 +0,0 @@
namespace TrashLib.Sonarr.ReleaseProfile;
public enum ReleaseProfileType
{
Anime,
Series
}

@ -5,6 +5,7 @@ using TrashLib.ExceptionTypes;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile.Guide;
namespace TrashLib.Sonarr.ReleaseProfile;
@ -12,44 +13,132 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater
{
private readonly ISonarrApi _api;
private readonly ISonarrCompatibility _compatibility;
private readonly IReleaseProfileGuideParser _parser;
private readonly ISonarrGuideService _guide;
private readonly ILogger _log;
public ReleaseProfileUpdater(
ILogger logger,
IReleaseProfileGuideParser parser,
ISonarrGuideService guide,
ISonarrApi api,
ISonarrCompatibility compatibility)
{
Log = logger;
_parser = parser;
_log = logger;
_guide = guide;
_api = api;
_compatibility = compatibility;
}
private ILogger Log { get; }
public async Task Process(bool isPreview, SonarrConfiguration config)
{
foreach (var profile in config.ReleaseProfiles)
var profilesFromGuide = _guide.GetReleaseProfileData();
var filteredProfiles = new List<(ReleaseProfileData Profile, IReadOnlyCollection<string> Tags)>();
var filterer = new ReleaseProfileDataFilterer(_log);
var configProfiles = config.ReleaseProfiles.SelectMany(x => x.TrashIds.Select(y => (TrashId: y, Config: x)));
foreach (var (trashId, configProfile) in configProfiles)
{
Log.Information("Processing Release Profile: {ProfileName}", profile.Type);
var markdown = await _parser.GetMarkdownData(profile.Type);
var profileData = _parser.ParseMarkdown(profile, markdown);
var profiles = Utils.FilterProfiles(profileData, profile.Filter);
// For each release profile specified in our YAML config, find the matching profile in the guide.
var selectedProfile = profilesFromGuide.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId));
if (selectedProfile is null)
{
_log.Warning("A release profile with Trash ID {TrashId} does not exist", trashId);
continue;
}
_log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name,
selectedProfile.TrashId);
if (profile.Filter.IncludeOptional)
if (configProfile.Filter != null)
{
Log.Information("Configuration is set to allow optional terms to be synchronized");
_log.Debug("This profile will be filtered");
var newProfile = filterer.FilterProfile(selectedProfile, configProfile.Filter);
if (newProfile is not null)
{
selectedProfile = newProfile;
}
}
if (isPreview)
{
Utils.PrintTermsAndScores(profiles);
Utils.PrintTermsAndScores(selectedProfile);
continue;
}
await ProcessReleaseProfiles(profiles, profile);
filteredProfiles.Add((selectedProfile, configProfile.Tags));
}
await ProcessReleaseProfiles(filteredProfiles);
}
private async Task ProcessReleaseProfiles(
List<(ReleaseProfileData Profile, IReadOnlyCollection<string> Tags)> profilesAndTags)
{
await DoVersionEnforcement();
// Obtain all of the existing release profiles first. If any were previously created by our program
// here, we favor replacing those instead of creating new ones, which would just be mostly duplicates
// (but with some differences, since there have likely been updates since the last run).
var existingProfiles = await _api.GetReleaseProfiles();
foreach (var (profile, tags) in profilesAndTags)
{
// If tags were provided, ensure they exist. Tags that do not exist are added first, so that we
// may specify them with the release profile request payload.
var tagIds = await CreateTagsInSonarr(tags);
var title = BuildProfileTitle(profile.Name);
var profileToUpdate = GetProfileToUpdate(existingProfiles, title);
if (profileToUpdate != null)
{
_log.Information("Update existing profile: {ProfileName}", title);
await UpdateExistingProfile(profileToUpdate, profile, tagIds);
}
else
{
_log.Information("Create new profile: {ProfileName}", title);
await CreateNewProfile(title, profile, tagIds);
}
}
// Any profiles with `[Trash]` in front of their name are managed exclusively by Trash Updater. As such, if
// there are any still in Sonarr that we didn't update, those are most certainly old and shouldn't be kept
// around anymore.
await DeleteOldManagedProfiles(profilesAndTags, existingProfiles);
}
private async Task DeleteOldManagedProfiles(
IEnumerable<(ReleaseProfileData Profile, IReadOnlyCollection<string> Tags)> profilesAndTags,
IEnumerable<SonarrReleaseProfile> sonarrProfiles)
{
var profiles = profilesAndTags.Select(x => x.Profile).ToList();
var sonarrProfilesToDelete = sonarrProfiles
.Where(sonarrProfile =>
{
return sonarrProfile.Name.StartsWithIgnoreCase("[Trash]") &&
!profiles.Any(profile => sonarrProfile.Name.EndsWithIgnoreCase(profile.Name));
});
foreach (var profile in sonarrProfilesToDelete)
{
_log.Information("Deleting old Trash release profile: {ProfileName}", profile.Name);
await _api.DeleteReleaseProfile(profile.Id);
}
}
private async Task<IReadOnlyCollection<int>> CreateTagsInSonarr(IReadOnlyCollection<string> tags)
{
if (!tags.Any())
{
return Array.Empty<int>();
}
var sonarrTags = await _api.GetTags();
await CreateMissingTags(sonarrTags, tags);
return sonarrTags
.Where(t => tags.Any(ct => ct.EqualsIgnoreCase(t.Label)))
.Select(t => t.Id)
.ToList();
}
private async Task DoVersionEnforcement()
@ -68,16 +157,17 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater
var missingTags = configTags.Where(t => !sonarrTags.Any(t2 => t2.Label.EqualsIgnoreCase(t)));
foreach (var tag in missingTags)
{
Log.Debug("Creating Tag: {Tag}", tag);
_log.Debug("Creating Tag: {Tag}", tag);
var newTag = await _api.CreateTag(tag);
sonarrTags.Add(newTag);
}
}
private string BuildProfileTitle(ReleaseProfileType profileType, string profileName)
private const string ProfileNamePrefix = "[Trash]";
private static string BuildProfileTitle(string profileName)
{
var titleType = profileType.ToString();
return $"[Trash] {titleType} - {profileName}";
return $"{ProfileNamePrefix} {profileName}";
}
private static SonarrReleaseProfile? GetProfileToUpdate(IEnumerable<SonarrReleaseProfile> profiles,
@ -86,83 +176,31 @@ internal class ReleaseProfileUpdater : IReleaseProfileUpdater
return profiles.FirstOrDefault(p => p.Name == profileName);
}
private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, FilteredProfileData profile,
List<int> tagIds)
private static void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, ReleaseProfileData profile,
IReadOnlyCollection<int> tagIds)
{
profileToUpdate.Preferred = profile.Preferred
.SelectMany(kvp => kvp.Value.Select(term => new SonarrPreferredTerm(kvp.Key, term)))
.SelectMany(x => x.Terms.Select(termData => new SonarrPreferredTerm(x.Score, termData.Term)))
.ToList();
profileToUpdate.Ignored = profile.Ignored.ToList(); //string.Join(',', profile.Ignored);
profileToUpdate.Required = profile.Required.ToList(); //string.Join(',', profile.Required);
// Null means the guide didn't specify a value for this, so we leave the existing setting intact.
if (profile.IncludePreferredWhenRenaming != null)
{
profileToUpdate.IncludePreferredWhenRenaming = profile.IncludePreferredWhenRenaming.Value;
}
profileToUpdate.Ignored = profile.Ignored.Select(x => x.Term).ToList();
profileToUpdate.Required = profile.Required.Select(x => x.Term).ToList();
profileToUpdate.IncludePreferredWhenRenaming = profile.IncludePreferredWhenRenaming;
profileToUpdate.Tags = tagIds;
}
private async Task UpdateExistingProfile(SonarrReleaseProfile profileToUpdate, FilteredProfileData profile,
List<int> tagIds)
private async Task UpdateExistingProfile(SonarrReleaseProfile profileToUpdate, ReleaseProfileData profile,
IReadOnlyCollection<int> tagIds)
{
Log.Debug("Update existing profile with id {ProfileId}", profileToUpdate.Id);
_log.Debug("Update existing profile with id {ProfileId}", profileToUpdate.Id);
SetupProfileRequestObject(profileToUpdate, profile, tagIds);
await _api.UpdateReleaseProfile(profileToUpdate);
}
private async Task CreateNewProfile(string title, FilteredProfileData profile, List<int> tagIds)
private async Task CreateNewProfile(string title, ReleaseProfileData profile, IReadOnlyCollection<int> tagIds)
{
var newProfile = new SonarrReleaseProfile
{
Name = title,
Enabled = true
};
var newProfile = new SonarrReleaseProfile {Name = title, Enabled = true};
SetupProfileRequestObject(newProfile, profile, tagIds);
await _api.CreateReleaseProfile(newProfile);
}
private async Task ProcessReleaseProfiles(IDictionary<string, ProfileData> profiles,
ReleaseProfileConfig config)
{
await DoVersionEnforcement();
List<int> tagIds = new();
// If tags were provided, ensure they exist. Tags that do not exist are added first, so that we
// may specify them with the release profile request payload.
if (config.Tags.Count > 0)
{
var sonarrTags = await _api.GetTags();
await CreateMissingTags(sonarrTags, config.Tags);
tagIds = sonarrTags.Where(t => config.Tags.Any(ct => ct.EqualsIgnoreCase(t.Label)))
.Select(t => t.Id)
.ToList();
}
// Obtain all of the existing release profiles first. If any were previously created by our program
// here, we favor replacing those instead of creating new ones, which would just be mostly duplicates
// (but with some differences, since there have likely been updates since the last run).
var existingProfiles = await _api.GetReleaseProfiles();
foreach (var (name, profileData) in profiles)
{
var filteredProfileData = new FilteredProfileData(profileData, config);
var title = BuildProfileTitle(config.Type, name);
var profileToUpdate = GetProfileToUpdate(existingProfiles, title);
if (profileToUpdate != null)
{
Log.Information("Update existing profile: {ProfileName}", title);
await UpdateExistingProfile(profileToUpdate, filteredProfileData, tagIds);
}
else
{
Log.Information("Create new profile: {ProfileName}", title);
await CreateNewProfile(title, filteredProfileData, tagIds);
}
}
}
}

@ -0,0 +1,29 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace TrashLib.Sonarr.ReleaseProfile;
internal class TermDataConverter : JsonConverter
{
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
throw new NotImplementedException();
}
public override object? ReadJson(JsonReader reader, Type objectType, object? existingValue,
JsonSerializer serializer)
{
var token = JToken.Load(reader);
return token.Type switch
{
JTokenType.Object => token.ToObject<TermData>(),
JTokenType.String => new TermData {Term = token.ToString()},
_ => null
};
}
public override bool CanConvert(Type objectType)
{
return objectType == typeof(TermData);
}
}

@ -1,56 +1,18 @@
using TrashLib.Sonarr.Config;
namespace TrashLib.Sonarr.ReleaseProfile;
using ProfileDataCollection = IDictionary<string, ProfileData>;
namespace TrashLib.Sonarr.ReleaseProfile;
public static class Utils
{
public static ProfileDataCollection FilterProfiles(ProfileDataCollection profiles, SonarrProfileFilterConfig filter)
public static void PrintTermsAndScores(ReleaseProfileData profile)
{
bool IsEmpty(ProfileData data)
static void PrintPreferredTerms(string title, IReadOnlyCollection<PreferredTermData> preferredTerms)
{
var isEmpty = data is
{
// Non-optional
Required.Count: 0,
Ignored.Count: 0,
Preferred.Count: 0
};
if (isEmpty && filter.IncludeOptional)
{
isEmpty = data is
{
// Optional
Optional.Required.Count: 0,
Optional.Ignored.Count: 0,
Optional.Preferred.Count: 0
};
}
return isEmpty;
}
// A few false-positive profiles are added sometimes. We filter these out by checking if they
// actually have meaningful data attached to them, such as preferred terms. If they are mostly empty,
// we remove them here.
return profiles
.Where(kv => !IsEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value);
}
public static void PrintTermsAndScores(ProfileDataCollection profiles)
{
static void PrintPreferredTerms(string title, IDictionary<int, List<string>> dict)
{
if (dict.Count <= 0)
if (preferredTerms.Count <= 0)
{
return;
}
Console.WriteLine($" {title}:");
foreach (var (score, terms) in dict)
foreach (var (score, terms) in preferredTerms)
{
foreach (var term in terms)
{
@ -61,7 +23,7 @@ public static class Utils
Console.WriteLine("");
}
static void PrintTerms(string title, ICollection<string> terms)
static void PrintTerms(string title, IReadOnlyCollection<TermData> terms)
{
if (terms.Count == 0)
{
@ -79,26 +41,17 @@ public static class Utils
Console.WriteLine("");
foreach (var (name, profile) in profiles)
{
Console.WriteLine(name);
Console.WriteLine(profile.Name);
if (profile.IncludePreferredWhenRenaming != null)
{
Console.WriteLine(" Include Preferred when Renaming?");
Console.WriteLine(" " +
(profile.IncludePreferredWhenRenaming.Value ? "CHECKED" : "NOT CHECKED"));
Console.WriteLine("");
}
Console.WriteLine(" Include Preferred when Renaming?");
Console.WriteLine(" " +
(profile.IncludePreferredWhenRenaming ? "YES" : "NO"));
Console.WriteLine("");
PrintTerms("Must Contain", profile.Required);
PrintTerms("Must Contain (Optional)", profile.Optional.Required);
PrintTerms("Must Not Contain", profile.Ignored);
PrintTerms("Must Not Contain (Optional)", profile.Optional.Ignored);
PrintPreferredTerms("Preferred", profile.Preferred);
PrintPreferredTerms("Preferred (Optional)", profile.Optional.Preferred);
PrintTerms("Must Contain", profile.Required);
PrintTerms("Must Not Contain", profile.Ignored);
PrintPreferredTerms("Preferred", profile.Preferred);
Console.WriteLine("");
}
Console.WriteLine("");
}
}

@ -3,6 +3,7 @@ using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.QualityDefinition;
using TrashLib.Sonarr.ReleaseProfile;
using TrashLib.Sonarr.ReleaseProfile.Guide;
namespace TrashLib.Sonarr;
@ -19,7 +20,7 @@ public class SonarrAutofacModule : Module
// Release Profile Support
builder.RegisterType<ReleaseProfileUpdater>().As<IReleaseProfileUpdater>();
builder.RegisterType<ReleaseProfileGuideParser>().As<IReleaseProfileGuideParser>();
builder.RegisterType<LocalRepoReleaseProfileJsonParser>().As<ISonarrGuideService>();
builder.RegisterType<SonarrReleaseProfileCompatibilityHandler>()
.As<ISonarrReleaseProfileCompatibilityHandler>();

@ -36,7 +36,7 @@ public class GitRepositoryFactory : IGitRepositoryFactory
var progress = new ProgressBar
{
Description = "Requesting and parsing guide markdown"
Description = "Fetching guide data"
};
_staticWrapper.Clone(repoUrl, repoPath, new CloneOptions

Loading…
Cancel
Save