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
commit 3ee7a8d866

@ -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<,>)}
};
}

@ -19,11 +19,12 @@
</AllowedReferenceRelatedFileExtensions>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>$(AssemblyName).Tests</_Parameter1>
</AssemblyAttribute>
<!-- For only NON-TEST projects -->
<ItemGroup Condition="!$(ProjectName.EndsWith('.Tests'))">
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
</ItemGroup>
<ItemGroup>
<!-- From https://github.com/dotnet/msbuild/pull/6285 -->
<AssemblyAttribute Include="System.CLSCompliantAttribute">
<_Parameter1>false</_Parameter1>

@ -1,4 +1,5 @@
using Autofac;
using System.Diagnostics.CodeAnalysis;
using Autofac;
using FluentAssertions;
using NUnit.Framework;
using Trash.Command;
@ -10,10 +11,14 @@ namespace Trash.Tests.Command.Helpers;
[Parallelizable(ParallelScope.All)]
public class CliTypeActivatorTest
{
// Warning CA1812 : an internal class that is apparently never instantiated.
[SuppressMessage("Performance", "CA1812", Justification = "Registered to and created by Autofac")]
private class NonServiceCommandType
{
}
// Warning CA1812 : an internal class that is apparently never instantiated.
[SuppressMessage("Performance", "CA1812", Justification = "Registered to and created by Autofac")]
private class StubCommand : IServiceCommand
{
public bool Preview => false;

@ -1,4 +1,5 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
using Autofac;
using Autofac.Core;
using FluentAssertions;
@ -39,6 +40,11 @@ public class CompositionRootTest
act.Should().NotThrow();
}
// Warning CA1812 : CompositionRootTest.ConcreteTypeEnumerator is an internal class that is apparently never
// instantiated.
[SuppressMessage("Performance", "CA1812",
Justification = "Created via reflection by TestCaseSource attribute"
)]
private sealed class ConcreteTypeEnumerator : IEnumerable
{
private readonly IContainer _container;

@ -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
{

@ -12,6 +12,7 @@ using TrashLib.Cache;
using TrashLib.Config;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Repo;
using TrashLib.Sonarr;
using TrashLib.Startup;
using VersionControl;
@ -83,6 +84,7 @@ public static class CompositionRoot
builder.RegisterModule<CacheAutofacModule>();
builder.RegisterType<CacheStoragePath>().As<ICacheStoragePath>();
builder.RegisterType<RepoUpdater>().As<IRepoUpdater>();
ConfigurationRegistrations(builder);
CommandRegistrations(builder);

@ -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");
}
}

@ -16,7 +16,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors;
public class PersistenceProcessorTest
{
[Test]
public void Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config()
public async Task Custom_formats_are_deleted_if_deletion_option_is_enabled_in_config()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
@ -30,13 +30,13 @@ public class PersistenceProcessorTest
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
await processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received().RecordDeletions(Arg.Is(deletedCfsInCache), Arg.Any<List<JObject>>());
}
[Test]
public void Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
public async Task Custom_formats_are_not_deleted_if_deletion_option_is_disabled_in_config()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
@ -50,14 +50,14 @@ public class PersistenceProcessorTest
var profileScores = new Dictionary<string, QualityProfileCustomFormatScoreMapping>();
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
await processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.DidNotReceive()
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());
}
[Test]
public void Different_active_configuration_is_properly_used()
public async Task Different_active_configuration_is_properly_used()
{
var steps = Substitute.For<IPersistenceProcessorSteps>();
var cfApi = Substitute.For<ICustomFormatService>();
@ -71,10 +71,10 @@ public class PersistenceProcessorTest
var processor = new PersistenceProcessor(cfApi, qpApi, configProvider, () => steps);
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = false};
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
await processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
configProvider.ActiveConfiguration = new RadarrConfiguration {DeleteOldCustomFormats = true};
processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
await processor.PersistCustomFormats(guideCfs, deletedCfsInCache, profileScores);
steps.JsonTransactionStep.Received(1)
.RecordDeletions(Arg.Any<IEnumerable<TrashIdMapping>>(), Arg.Any<List<JObject>>());

@ -18,7 +18,7 @@ namespace TrashLib.Tests.Radarr.CustomFormat.Processors.PersistenceSteps;
public class QualityProfileApiPersistenceStepTest
{
[Test]
public void Do_not_invoke_api_if_no_scores_to_update()
public async Task Do_not_invoke_api_if_no_scores_to_update()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
@ -56,13 +56,13 @@ public class QualityProfileApiPersistenceStepTest
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
await processor.Process(api, cfScores);
api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
await api.DidNotReceive().UpdateQualityProfile(Arg.Any<JObject>(), Arg.Any<int>());
}
[Test]
public void Invalid_quality_profile_names_are_reported()
public async Task Invalid_quality_profile_names_are_reported()
{
const string radarrQualityProfileData = @"[{'name': 'profile1'}]";
@ -75,14 +75,14 @@ public class QualityProfileApiPersistenceStepTest
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
await processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().Equal("wrong_profile_name");
processor.UpdatedScores.Should().BeEmpty();
}
[Test]
public void Reset_scores_for_unmatched_cfs_if_enabled()
public async Task Reset_scores_for_unmatched_cfs_if_enabled()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
@ -120,7 +120,7 @@ public class QualityProfileApiPersistenceStepTest
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
await processor.Process(api, cfScores);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()
@ -132,13 +132,13 @@ public class QualityProfileApiPersistenceStepTest
new("cf3", 0, FormatScoreUpdateReason.Reset)
});
api.Received().UpdateQualityProfile(
await api.Received().UpdateQualityProfile(
Verify.That<JObject>(j => j["formatItems"]!.Children().Should().HaveCount(3)),
Arg.Any<int>());
}
[Test]
public void Scores_are_set_in_quality_profile()
public async Task Scores_are_set_in_quality_profile()
{
const string radarrQualityProfileData = @"[{
'name': 'profile1',
@ -207,7 +207,7 @@ public class QualityProfileApiPersistenceStepTest
};
var processor = new QualityProfileApiPersistenceStep();
processor.Process(api, cfScores);
await processor.Process(api, cfScores);
var expectedProfileJson = JObject.Parse(@"{
'name': 'profile1',
@ -250,7 +250,7 @@ public class QualityProfileApiPersistenceStepTest
'id': 1
}");
api.Received()
await api.Received()
.UpdateQualityProfile(Verify.That<JObject>(a => a.Should().BeEquivalentTo(expectedProfileJson)), 1);
processor.InvalidProfileNames.Should().BeEmpty();
processor.UpdatedScores.Should()

@ -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,81 +1,25 @@
using System.IO.Abstractions;
using Common;
using LibGit2Sharp;
using Serilog;
using TrashLib.Config.Settings;
using TrashLib.Radarr.Config;
using VersionControl;
namespace TrashLib.Radarr.CustomFormat.Guide;
internal class LocalRepoCustomFormatJsonParser : IRadarrGuideService
public class LocalRepoCustomFormatJsonParser : IRadarrGuideService
{
private readonly ILogger _log;
private readonly IFileSystem _fileSystem;
private readonly IGitRepositoryFactory _repositoryFactory;
private readonly IFileUtilities _fileUtils;
private readonly ISettingsProvider _settingsProvider;
private readonly string _repoPath;
private readonly IResourcePaths _paths;
public LocalRepoCustomFormatJsonParser(
ILogger log,
IFileSystem fileSystem,
IResourcePaths paths,
IGitRepositoryFactory repositoryFactory,
IFileUtilities fileUtils,
ISettingsProvider settingsProvider)
public LocalRepoCustomFormatJsonParser(IFileSystem fileSystem, IResourcePaths paths)
{
_log = log;
_fileSystem = fileSystem;
_repositoryFactory = repositoryFactory;
_fileUtils = fileUtils;
_settingsProvider = settingsProvider;
_repoPath = paths.RepoPath;
_paths = paths;
}
public IEnumerable<string> GetCustomFormatJson()
{
// Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start
// fresh.
var exception = CheckoutAndUpdateRepo();
if (exception is not null)
{
_log.Information("Deleting local git repo and retrying git operation...");
_fileUtils.DeleteReadOnlyDirectory(_repoPath);
exception = CheckoutAndUpdateRepo();
if (exception is not null)
{
throw exception;
}
}
var jsonDir = Path.Combine(_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;
}
private Exception? CheckoutAndUpdateRepo()
{
var repoSettings = _settingsProvider.Settings.Repository;
var cloneUrl = repoSettings.CloneUrl;
const string branch = "master";
try
{
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, _repoPath, branch);
repo.ForceCheckout(branch);
repo.Fetch();
repo.ResetHard($"origin/{branch}");
}
catch (LibGit2SharpException e)
{
_log.Error(e, "An exception occurred during git operations on path: {RepoPath}", _repoPath);
return e;
}
return null;
}
}

@ -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;

@ -0,0 +1,7 @@
namespace TrashLib.Repo;
public interface IRepoUpdater
{
string RepoPath { get; }
void UpdateRepo();
}

@ -0,0 +1,72 @@
using Common;
using LibGit2Sharp;
using Serilog;
using TrashLib.Config.Settings;
using TrashLib.Radarr.Config;
using VersionControl;
namespace TrashLib.Repo;
public class RepoUpdater : IRepoUpdater
{
private readonly ILogger _log;
private readonly IGitRepositoryFactory _repositoryFactory;
private readonly IFileUtilities _fileUtils;
private readonly ISettingsProvider _settingsProvider;
public RepoUpdater(
ILogger log,
IResourcePaths paths,
IGitRepositoryFactory repositoryFactory,
IFileUtilities fileUtils,
ISettingsProvider settingsProvider)
{
_log = log;
_repositoryFactory = repositoryFactory;
_fileUtils = fileUtils;
_settingsProvider = settingsProvider;
RepoPath = paths.RepoPath;
}
public string RepoPath { get; }
public void UpdateRepo()
{
// Retry only once if there's a failure. This gives us an opportunity to delete the git repository and start
// fresh.
var exception = CheckoutAndUpdateRepo();
if (exception is not null)
{
_log.Information("Deleting local git repo and retrying git operation...");
_fileUtils.DeleteReadOnlyDirectory(RepoPath);
exception = CheckoutAndUpdateRepo();
if (exception is not null)
{
throw exception;
}
}
}
private Exception? CheckoutAndUpdateRepo()
{
var repoSettings = _settingsProvider.Settings.Repository;
var cloneUrl = repoSettings.CloneUrl;
const string branch = "master";
try
{
using var repo = _repositoryFactory.CreateAndCloneIfNeeded(cloneUrl, RepoPath, branch);
repo.ForceCheckout(branch);
repo.Fetch();
repo.ResetHard($"origin/{branch}");
}
catch (LibGit2SharpException e)
{
_log.Error(e, "An exception occurred during git operations on path: {RepoPath}", RepoPath);
return e;
}
return null;
}
}

@ -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

@ -0,0 +1,22 @@
Special notes about behavior of Trash Updater with regards to the various services it supports will
be documented here.
# Sonarr
Each section below represents a topic covering certain behavior relevant to Sonarr.
## Release Profile Naming
The script procedurally generates a name for release profiles it creates. For the following example:
```txt
[Trash] Anime - First Release Profile
```
The name is generated as follows:
- `[Trash]` is added by Trash Updater to indicate that this Release Profile is created and managed
by it. This prefix exists to separate it from any Release Profiles the user may have manually
created (which Trash Updater will not touch).
- `Anime - First Release Profile` is the name of the Release Profile (taken from the `name` property
of its corresponding JSON file).

@ -21,14 +21,12 @@ sonarr:
api_key: f7e74ba6c80046e39e076a27af5a8444
quality_definition: hybrid
release_profiles:
- type: anime
strict_negative_scores: true
tags:
- anime
- type: series
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
strict_negative_scores: false
tags:
- tv
tags: [tv]
radarr:
- base_url: http://localhost:7878
@ -62,9 +60,10 @@ sonarr:
- base_url: http://localhost:8989
api_key: f7e74ba6c80046e39e076a27af5a8444
release_profiles:
- type: anime
tags:
- anime
- trash_ids:
- d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
- 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
tags: [anime]
```
`sonarr-quality-definition.yml`:
@ -101,12 +100,17 @@ sonarr:
api_key: f7e74ba6c80046e39e076a27af5a8444
quality_definition: anime
release_profiles:
- type: anime
- trash_ids:
- d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
- 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
- base_url: http://instance_two:8989
api_key: bf99da49d0b0488ea34e4464aa63a0e5
quality_definition: series
release_profiles:
- type: series
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
```
In the example above, two separate instances, each with its own API key, will be updated. One

@ -43,16 +43,23 @@ sonarr:
# Release Profile Settings
release_profiles:
- type: anime
- trash_ids:
- d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
- 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
strict_negative_scores: true
tags:
- anime
- type: series
tags: [anime]
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
strict_negative_scores: false
tags: [tv]
- trash_ids: [76e060895c5b8a765c310933da0a5357] # Optionals
filter:
include_optional: true
tags:
- tv
include:
- 436f5a7d08fbf02ba25cb5e5dfe98e55 # Ignore Dolby Vision without HDR10 fallback
- f3f0f3691c6a1988d4a02963e69d11f2 # Ignore The Group -SCENE
tags: [tv]
```
### Basic Settings
@ -91,11 +98,8 @@ sonarr:
A list of release profiles to parse from the guide. Each object in this list supports the below
properties.
- `type` **(Required)**<br>
Must be one of the following values:
- `anime`: Parse the [Anime Release Profile][sonarr_profile_anime] page from the TRaSH Guide.
- `series`: Parse the [WEB-DL Release Profile][sonarr_profile_series] page from the TRaSH Guide.
- `trash_ids` **(Required)**<br>
A list of one or more Trash IDs taken from [the Trash Guide Sonarr JSON files][sonarrjson].
- `strict_negative_scores` (Optional; *Default: `false`*)<br>
Enables preferred term scores less than 0 to be instead treated as "Must Not Contain" (ignored)
@ -108,16 +112,23 @@ sonarr:
present) are removed and replaced with only the tags in this list. If no tags are specified, no
tags will be set on the release profile.
- `filter` (Optional; *Default: Determined by child properties*)<br>
- `filter` (Optional)<br>
Defines various ways that release profile terms from the guide are synchronized with Sonarr. Any
combination of the below properties may be specified here:
filters below that takes a list of `trash_id` values, those values come, again, from the [Sonarr
JSON Files][sonarrjson]. There is a `trash_id` field next to each `term` field; that is what you
use.
- `include`<br>
A list of `trash_id` values representing terms (Required, Ignored, or Preferred) that should
be included in the created Release Profile in Sonarr. Terms that are NOT specified here are
excluded automatically. Not compatible with `exclude` and will take precedence over it.
- `include_optional` (Optional; *Default: `false`*)<br>
Set to `true` to include terms marked "Optional" in the guide. If set to `false`, optional
terms are *not* synchronized to Sonarr.
- `exclude`<br>
A list of `trash_id` values representing terms (Required, Ignored, or Preferred) that should
be excluded from the created Release Profile in Sonarr. Terms that are NOT specified here are
included automatically. Not compatible with `include`; this list is not used if it is present.
[sonarr_profile_anime]: https://trash-guides.info/Sonarr/Sonarr-Release-Profile-RegEx-Anime/
[sonarr_profile_series]: https://trash-guides.info/Sonarr/Sonarr-Release-Profile-RegEx/
[sonarrjson]: https://github.com/TRaSH-/Guides/tree/master/docs/json/sonarr
## Radarr

@ -1,119 +0,0 @@
In order for the `trash.py` script to remain as stable as possible between updates to the TRaSH
guides, the following structural guidelines are provided. This document also serves as documentation
on how the python script is implemented currently.
# Definitions
- **Term**<br>
A phrase that is included in Sonarr release profiles under either the "Preferred", "Must Contain",
or "Must Not Contain" sections. In the TRaSH guides these are regular expressions.
- **Ignored**<br>
The API term for "Must Not Contain"
- **Required**<br>
The API term for "Must Contain"
- **Category**<br>
Refers to any of the different "sections" in a release profile where terms may be stored. Includes
"Must Not Contain" (ignored), "Must Contain" (required), and "Preferred".
- **Mention**<br>
This generally refers to any human-readable way of stating something that the script relies on for
parsing purposes.
# Structural Guidelines
Different types of TRaSH guides are parsed in their own unique way, mostly because the data set is
different. In order to ensure the script continues to be reliable, it's important that the structure
of the guides do not change. The following sections outline various guidelines to help achieve this
goal.
Note that all parsing happens directly on the markdown files themselves from the TRaSH github
repository. Those files are processed one line at a time. Guidelines will apply on a per-line basis,
unless otherwise stated.
The following general rules apply to all lines in the markdown data:
- Lines with leading whitespace (indentation) are skipped
- Blank lines are skipped
- Admonition lines (starting with `!!!` or `???`) are skipped.
## Sonarr Release Profiles
1. **Headers define release profiles.**
A header with the phrase `Release Profile` in it will start a new release profile. The header
name may contain other keywords before or after that phrase, such as `First Release Profile`.
This header name in its entirety will be used as part of the release profile name when the data
is pushed to Sonarr.
1. **Fenced code blocks must *only* contain ignored, required, or preferred terms.**
Between headers, fenced code blocks indicate the terms that will be captured and pushed to Sonarr
for any given type of category (required, preferred, or ignored). There may be more than one
fenced code block, and each fenced code block may have more than one line inside of it. Each line
inside of a fenced code block is treated as 1 single term. Commas at the end of each line are
removed, if they are present.
1. **For preferred terms, a score must be mentioned prior to the first fenced code block.**
Each separate line in the markdown file is inspected for the word `score` followed by a number
inside square brackets, such as `[100]`. If found, the score between the brackets is captured and
applied to any future terms found within fenced code blocks. Between fenced code blocks under the
same heading, a new score using these same rules may be mentioned to change it again.
Terms mentioned prior to a score being set are discarded.
1. **Categories shall be specified before the first fenced code block.**
Categories are technically optional; if one is never explicitly mentioned in the guide, the
default is "Preferred". Depending on the category, certain requirements change. At the moment, if
"Preferred" is used, this also requires a score. However "Must Not Contain" and "Must Contain" do
not require a score.
A category must mentioned as one of the following phrases (case insensitive):
- `Preferred`
- `Must Not Contain`
- `Must Contain`
These phrases may appear in nested headers, normal lines, and may even appear inside the same
line that defines a score (e.g. `Insert these as "Preferred" with a score of [100]`).
1. **"Include Preferred when Renaming" may be optionally set via mention.**
If you wish to control the checked/unchecked state of the "Include Preferred when Renaming"
option in a release profile, simply mention the phrase `include preferred` (case-insensitive) on
any single line. This marks it as "CHECKED". If it also finds the word `not` on that same line,
it will instead be marked "UNCHECKED".
This is optional and the default is always "UNCHECKED".
1. **Terms may be marked "optional".**
From a header or sentence within a header section, the appearance of the word "optional" will
indicate that certain terms will *not* be synchronized to Sonarr by default. The semantics for
this differ depending on where the word is mentioned:
- **Headers**: "Optional" mentioned in a header will apply to all code blocks in that whole
section. Furthermore, any nested headers will also be treated as if the word "Optional" appears
in its name, even if it isn't.
- **Sentence**: Once the term "Optional" is found in any sentence following a header, it applies
to all code blocks for the remainder of that section. Once a new header is found (nested or
not), terms are not considered optional anymore.
### Release Profile Naming
The script procedurally generates a name for release profiles it creates. For the following example:
```txt
[Trash] Anime - First Release Profile
```
The name is generated as follows:
- `Anime` comes from the guide type (could be `WEB-DL`)
- `First Release Profile` is directly from one of the headers in the anime guide
- `[Trash]` is used by the script to mean "This release profile is controlled by the script". This
is to separate it from any manual ones the user has defined, which the script will not touch.

@ -0,0 +1,86 @@
# Version 2.0
This version introduces changes to the way Sonarr Release Profiles are specified in your YAML
configuration (`trash.yml`). As such, changes are required to your YAML to avoid errors. First,
visit the "Series Types" section to replace the `type` attribute with `trash_ids` as needed. Then
check out the "Term Filters" section to see about removing the `include_optionals` property.
## Series Types
The `type` property under `release_profiles` has been removed. Replaced by a new `trash_ids`
property.
### Drop-In Replacement for Series
For `series`, replace this:
```yml
release_profiles:
- type: series
```
With this (or you can customize it if you want less):
```yml
release_profiles:
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
```
### Drop-In Replacement for Anime
For `series`, replace this:
```yml
release_profiles:
- type: anime
```
With this (or you can customize it if you want less):
```yml
release_profiles:
- trash_ids:
- d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
- 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
```
## Term Filters
The following changes apply to YAML under the `filter` property.
- Property `include_optional` removed.
- `include` and `exclude` properties added to explicitly choose terms to include or exclude,
respectively.
### Replacement Examples
If you are coming from YAML like this:
```yml
release_profiles:
- trash_ids: [EBC725268D687D588A20CBC5F97E538B]
strict_negative_scores: false
filter:
include_optional: true
tags:
- tv
```
Simply remove the `include_optional` property above, to get this:
```yml
release_profiles:
- trash_ids: [EBC725268D687D588A20CBC5F97E538B]
strict_negative_scores: false
tags:
- tv
```
In this release, since you now have the ability to specifically include optionals that you want, I
recommend visiting the [Configuration Reference] and learning more about the `include` and `exclude`
filter lists.
[Configuration Reference]: https://github.com/rcdailey/trash-updater/wiki/Configuration-Reference
Loading…
Cancel
Save