From af97311899dd622ee72ee669d16e38762db0e1c7 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 18 Apr 2021 18:09:43 -0500 Subject: [PATCH] feat: Ignore optional terms in Release Profiles Terms marked "optional" (preferred, ignored, and required) are now separated out from the main list of terms. Right now, any optional terms are NOT uploaded to Sonarr. In the future, I plan to add ways to explicitly include optional terms. The structure for optional terms in the guide is similar to that of categories. A header OR line within the header section can mention the word "optional" and that means any code blocks past that point until the end of the section are treated as optional. Other changes: - Delete test trash.yml - Add new ScopedState class Used to manage resetting certain parser state between different sections of the guide (for single code blocks or whole header sections) --- CHANGELOG.md | 4 + .../Config/ConfigurationLoaderTest.cs | 1 + .../Extensions/DictionaryExtensionsTest.cs | 1 + .../ReleaseProfileParserTest.cs | 287 +++++++++++++++++- .../Sonarr/ReleaseProfile/ScopedStateTest.cs | 131 ++++++++ .../Sonarr/ReleaseProfileUpdaterTest.cs | 1 + .../Sonarr/ReleaseProfile/ParserState.cs | 84 +++++ .../Sonarr/ReleaseProfile/ProfileData.cs | 15 +- .../ReleaseProfileGuideParser.cs | 229 ++++++++------ .../Sonarr/ReleaseProfile/ScopedState.cs | 61 ++++ src/Trash/Sonarr/ReleaseProfile/Utils.cs | 67 ++-- src/Trash/trash.yml | 45 --- 12 files changed, 744 insertions(+), 182 deletions(-) create mode 100644 src/Trash.Tests/Sonarr/ReleaseProfile/ScopedStateTest.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ParserState.cs create mode 100644 src/Trash/Sonarr/ReleaseProfile/ScopedState.cs delete mode 100644 src/Trash/trash.yml diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f7c7a2c..a04b177d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Optional terms in the release profile guides are no longer synchronized to Sonarr. + ### Changed - A warning is now logged when we find a number in brackets (such as `[100]`) without the word diff --git a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs index cf705d05..4ff9f2ff 100644 --- a/src/Trash.Tests/Config/ConfigurationLoaderTest.cs +++ b/src/Trash.Tests/Config/ConfigurationLoaderTest.cs @@ -17,6 +17,7 @@ using YamlDotNet.Serialization.ObjectFactories; namespace Trash.Tests.Config { [TestFixture] + [Parallelizable(ParallelScope.All)] public class ConfigurationLoaderTest { private TextReader GetResourceData(string file) diff --git a/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs b/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs index f3e8fabe..5ba010c0 100644 --- a/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs +++ b/src/Trash.Tests/Extensions/DictionaryExtensionsTest.cs @@ -6,6 +6,7 @@ using Trash.Extensions; namespace Trash.Tests.Extensions { [TestFixture] + [Parallelizable(ParallelScope.All)] public class DictionaryExtensionsTest { private class MySampleValue diff --git a/src/Trash.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs b/src/Trash.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs index f19b4c54..37ebdad6 100644 --- a/src/Trash.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs +++ b/src/Trash.Tests/Sonarr/ReleaseProfile/ReleaseProfileParserTest.cs @@ -12,8 +12,15 @@ using Trash.Sonarr.ReleaseProfile; namespace Trash.Tests.Sonarr.ReleaseProfile { [TestFixture] + [Parallelizable(ParallelScope.All)] public class ReleaseProfileParserTest { + [OneTimeSetUp] + public void Setup() + { + // Formatter.AddFormatter(new ProfileDataValueFormatter()); + } + private class Context { public Context() @@ -32,6 +39,77 @@ namespace Trash.Tests.Sonarr.ReleaseProfile public SonarrConfiguration Config { get; } public ReleaseProfileGuideParser GuideParser { get; } public TestData TestData { get; } = new(); + + public IDictionary 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") + .WhichValue.Should().BeEquivalentTo(new + { + Ignored = new List {"abc"}, + Required = new List {"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") + .WhichValue.Should().BeEquivalentTo(new + { + Ignored = new List {"abc"}, + Required = new List {"xyz", "123"} + }); } [Test] @@ -55,7 +133,7 @@ namespace Trash.Tests.Sonarr.ReleaseProfile { var context = new Context(); var markdown = context.TestData.GetResourceData("include_preferred_when_renaming.md"); - var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown); + var results = context.ParseWithDefaults(markdown); results.Should() .ContainKey("First Release Profile") @@ -65,6 +143,124 @@ namespace Trash.Tests.Sonarr.ReleaseProfile .WhichValue.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 + { + { + "Release Profile 1", new ProfileData + { + IncludePreferredWhenRenaming = false, + Required = new List {"test1"} + } + }, + { + "Release Profile 2", new ProfileData + { + IncludePreferredWhenRenaming = true, + Required = new List {"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 + { + { + "Optional Release Profile", new ProfileData + { + Optional = new ProfileDataOptional + { + Ignored = new List {"optional1"}, + Required = new List {"optional3"}, + Preferred = new Dictionary> + { + {10, new List {"optional2"}} + } + } + } + }, + { + "Second Release Profile", new ProfileData + { + Ignored = new List {"not-optional1"} + } + } + }; + + results.Should().BeEquivalentTo(expectedResults); + } + [Test] public void Parse_PotentialScore_WarningLogged() { @@ -80,10 +276,9 @@ abc ``` "); var context = new Context(); - var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown); + var results = context.ParseWithDefaults(markdown); - results.Should().ContainKey("First Release Profile") - .WhichValue.Should().BeEquivalentTo(new ProfileData()); + results.Should().BeEmpty(); const string expectedLog = "Found a potential score on line #5 that will be ignored because the " + @@ -93,6 +288,30 @@ abc .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") + .WhichValue.Preferred.Should() + .BeEquivalentTo(new Dictionary> + { + {100, new List {"abc"}} + }); + } + [Test] public void Parse_SkippableLines_AreSkippedWithLog() { @@ -111,7 +330,7 @@ abc }; var context = new Context(); - var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown); + var results = context.ParseWithDefaults(markdown); results.Should().BeEmpty(); @@ -132,7 +351,7 @@ abc }; var markdown = context.TestData.GetResourceData("strict_negative_scores.md"); - var results = context.GuideParser.ParseMarkdown(context.Config.ReleaseProfiles.First(), markdown); + var results = context.ParseWithDefaults(markdown); results.Should() .ContainKey("Test Release Profile") @@ -144,5 +363,61 @@ abc Preferred = new Dictionary> {{0, new List {"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 + { + { + "Test Release Profile", new ProfileData + { + Ignored = new List {"added1"}, + Preferred = new Dictionary> + { + {10, new List {"added2", "added3"}} + } + } + } + }; + + results.Should().BeEquivalentTo(expectedResults); + } } } diff --git a/src/Trash.Tests/Sonarr/ReleaseProfile/ScopedStateTest.cs b/src/Trash.Tests/Sonarr/ReleaseProfile/ScopedStateTest.cs new file mode 100644 index 00000000..faeddee1 --- /dev/null +++ b/src/Trash.Tests/Sonarr/ReleaseProfile/ScopedStateTest.cs @@ -0,0 +1,131 @@ +using FluentAssertions; +using NUnit.Framework; +using Trash.Sonarr.ReleaseProfile; + +namespace Trash.Tests.Sonarr.ReleaseProfile +{ + [TestFixture] + [Parallelizable(ParallelScope.All)] + public class ScopedStateTest + { + [Test] + public void AccessValue_MultipleScopes_ScopeValuesReturned() + { + var state = new ScopedState(50); + state.PushValue(100, 0); + state.PushValue(150, 1); + + state.StackSize.Should().Be(2); + state.ActiveScope.Should().Be(1); + state.Value.Should().Be(150); + + state.Reset(1).Should().BeTrue(); + + state.StackSize.Should().Be(1); + state.ActiveScope.Should().Be(0); + state.Value.Should().Be(100); + + state.Reset(0).Should().BeTrue(); + + state.StackSize.Should().Be(0); + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + + [Test] + public void AccessValue_NextBlockScope_ReturnValueUntilSecondSession() + { + var state = new ScopedState(50); + state.PushValue(100, 0); + + state.ActiveScope.Should().Be(0); + state.Value.Should().Be(100); + + state.Reset(0).Should().BeTrue(); + + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + + [Test] + public void AccessValue_NoScope_ReturnDefaultValue() + { + var state = new ScopedState(50); + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + + [Test] + public void AccessValue_ResetAfterScope_ReturnDefault() + { + var state = new ScopedState(50); + state.PushValue(100, 1); + + state.Reset(1).Should().BeTrue(); + + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + + [Test] + public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets() + { + var state = new ScopedState(50); + state.PushValue(100, 1); + + state.ActiveScope.Should().Be(1); + state.Value.Should().Be(100); + + state.Reset(2).Should().BeFalse(); + + state.ActiveScope.Should().Be(1); + state.Value.Should().Be(100); + } + + [Test] + public void Reset_UsingGreatestScopeWithTwoScopes_ShouldRemoveAllScope() + { + var state = new ScopedState(50); + state.PushValue(100, 1); + state.PushValue(150, 0); + state.Reset(1).Should().BeTrue(); + + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + + [Test] + public void Reset_UsingLesserScopeWithTwoScopes_ShouldRemoveTopScope() + { + var state = new ScopedState(50); + state.PushValue(100, 0); + state.PushValue(150, 1); + state.Reset(1).Should().BeTrue(); + + state.ActiveScope.Should().Be(0); + state.Value.Should().Be(100); + } + + [Test] + public void Reset_WithLesserScope_ShouldDoNothing() + { + var state = new ScopedState(50); + state.PushValue(100, 1); + state.Reset(2).Should().BeFalse(); + + state.ActiveScope.Should().Be(1); + state.Value.Should().Be(100); + } + + [Test] + public void Reset_WithScope_ShouldReset() + { + var state = new ScopedState(50); + state.PushValue(100, 1); + state.Reset(1).Should().BeTrue(); + + state.ActiveScope.Should().BeNull(); + state.Value.Should().Be(50); + } + } +} diff --git a/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs b/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs index 575fc75c..64165532 100644 --- a/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs +++ b/src/Trash.Tests/Sonarr/ReleaseProfileUpdaterTest.cs @@ -8,6 +8,7 @@ using Trash.Sonarr.ReleaseProfile; namespace Trash.Tests.Sonarr { [TestFixture] + [Parallelizable(ParallelScope.All)] public class ReleaseProfileUpdaterTest { private class Context diff --git a/src/Trash/Sonarr/ReleaseProfile/ParserState.cs b/src/Trash/Sonarr/ReleaseProfile/ParserState.cs new file mode 100644 index 00000000..add7f0a8 --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ParserState.cs @@ -0,0 +1,84 @@ +using System; +using System.Collections.Generic; +using Serilog; +using Trash.Extensions; + +namespace Trash.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 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 Results { get; } = new Dictionary(); + + // If null, then terms are not considered optional + public ScopedState 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 List IgnoredTerms + => TermsAreOptional.Value ? Profile.Optional.Ignored : Profile.Ignored; + + public List RequiredTerms + => TermsAreOptional.Value ? Profile.Optional.Required : Profile.Required; + + public Dictionary> PreferredTerms + => TermsAreOptional.Value ? Profile.Optional.Preferred : Profile.Preferred; + + public ProfileData Profile + { + get + { + 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); + } + } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs b/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs index 7dc2813c..1e543d25 100644 --- a/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs +++ b/src/Trash/Sonarr/ReleaseProfile/ProfileData.cs @@ -2,16 +2,25 @@ namespace Trash.Sonarr.ReleaseProfile { + public class ProfileDataOptional + { + public List Required { get; init; } = new(); + public List Ignored { get; init; } = new(); + public Dictionary> Preferred { get; init; } = new(); + } + public class ProfileData { - public List Required { get; } = new(); - public List Ignored { get; } = new(); - public Dictionary> Preferred { get; } = new(); + public List Required { get; init; } = new(); + public List Ignored { get; init; } = new(); + public Dictionary> Preferred { get; init; } = new(); // 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(); } } diff --git a/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs index 8517ab9c..a97c0514 100644 --- a/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs +++ b/src/Trash/Sonarr/ReleaseProfile/ReleaseProfileGuideParser.cs @@ -5,7 +5,6 @@ using System.Text.RegularExpressions; using System.Threading.Tasks; using Flurl; using Flurl.Http; -using Newtonsoft.Json.Linq; using Serilog; using Trash.Extensions; @@ -26,7 +25,7 @@ namespace Trash.Sonarr.ReleaseProfile (TermCategory.Preferred, BuildRegex(@"preferred")) }; - private readonly Regex _regexHeader = new(@"^(#+)\s([\w\s\d]+)\s*$", RegexOptions.Compiled); + 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]+)\]"); @@ -45,14 +44,13 @@ namespace Trash.Sonarr.ReleaseProfile public IDictionary ParseMarkdown(ReleaseProfileConfig config, string markdown) { - var results = new Dictionary(); - var state = new ParserState(); + var state = new ParserState(Log); var reader = new StringReader(markdown); for (var line = reader.ReadLine(); line != null; line = reader.ReadLine()) { state.LineNumber++; - if (IsSkippableLine(line)) + if (string.IsNullOrEmpty(line)) { continue; } @@ -61,17 +59,17 @@ namespace Trash.Sonarr.ReleaseProfile // the logic we use. if (line.StartsWith("```")) { - state.BracketDepth = 1 - state.BracketDepth; + state.InsideCodeBlock = !state.InsideCodeBlock; continue; } // Not inside brackets - if (state.BracketDepth == 0) + if (!state.InsideCodeBlock) { - ParseMarkdownOutsideFence(line, state, results); + OutsideFence_ParseMarkdown(line, state); } // Inside brackets - else if (state.BracketDepth == 1) + else { if (!state.IsValid) { @@ -79,26 +77,21 @@ namespace Trash.Sonarr.ReleaseProfile "[Profile Name: {ProfileName}] " + "[Category: {Category}] " + "[Score: {Score}] " + "[Line: {Line}] ", state.ProfileName, - state.CurrentCategory, state.Score, line); + state.CurrentCategory.Value, state.Score, line); } else { - ParseMarkdownInsideFence(config, line, state, results); + InsideFence_ParseMarkdown(config, line, state); } } } Log.Debug("\n"); - return results; + return state.Results; } private bool IsSkippableLine(string line) { - if (string.IsNullOrEmpty(line)) - { - return true; - } - // 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)) @@ -128,35 +121,34 @@ namespace Trash.Sonarr.ReleaseProfile $"{_markdownDocNames[profileName]}.md"); } - private void ParseMarkdownInsideFence(ReleaseProfileConfig config, string line, ParserState state, - IDictionary results) + private void InsideFence_ParseMarkdown(ReleaseProfileConfig config, string line, ParserState state) { - // ProfileName is verified for validity prior to this method being invoked. - // The actual check occurs in the call to ParserState.IsValid. - var profile = results.GetOrCreate(state.ProfileName!); - // 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(','); - switch (state.CurrentCategory) + var category = state.CurrentCategory.Value; + switch (category!.Value) { case TermCategory.Preferred: { - Log.Debug(" + Capture Term " + "[Category: {CurrentCategory}] " + "[Score: {Score}] " + - "[Strict: {StrictNegativeScores}] " + "[Term: {Line}]", state.CurrentCategory, - state.Score, - config.StrictNegativeScores, line); + 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) { - profile.Ignored.Add(line); + state.IgnoredTerms.Add(line); } else { // Score is already checked for null prior to the method being invoked. - var prefList = profile.Preferred.GetOrCreate(state.Score!.Value); + var prefList = state.PreferredTerms.GetOrCreate(state.Score!.Value); prefList.Add(line); } @@ -165,53 +157,43 @@ namespace Trash.Sonarr.ReleaseProfile case TermCategory.Ignored: { - profile.Ignored.Add(line); - Log.Debug(" + Capture Term [Category: {Category}] [Term: {Line}]", state.CurrentCategory, line); + state.IgnoredTerms.Add(line); + Log.Debug(" + Capture Term " + + "[Category: {Category}] " + + "[Optional: {Optional}] " + + "[Term: {Line}]", + category.Value, state.TermsAreOptional.Value, line); break; } case TermCategory.Required: { - profile.Required.Add(line); - Log.Debug(" + Capture Term [Category: {Category}] [Term: {Line}]", state.CurrentCategory, line); + 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: {state.CurrentCategory}"); + throw new ArgumentOutOfRangeException($"Unknown term category: {category.Value}"); } } } - private void ParseMarkdownOutsideFence(string line, ParserState state, IDictionary results) + private void OutsideFence_ParseMarkdown(string line, ParserState state) { // ReSharper disable once InlineOutVariableDeclaration Match match; - // Header Processing + // Header Processing. Never do any additional processing to headers, so return after processing it if (_regexHeader.Match(line, out match)) { - var headerDepth = match.Groups[1].Length; - var headerText = match.Groups[2].Value; - Log.Debug("> Parsing Header [Text: {HeaderText}] [Depth: {HeaderDepth}]", headerText, headerDepth); - - // Profile name (always reset previous state here) - if (_regexHeaderReleaseProfile.Match(headerText).Success) - { - state.Reset(); - state.ProfileName = headerText; - state.CurrentHeaderDepth = headerDepth; - Log.Debug(" - New Profile [Text: {HeaderText}]", headerText); - return; - } - - if (headerDepth <= state.CurrentHeaderDepth) - { - Log.Debug(" - !! Non-nested, non-profile header found; resetting all state"); - state.Reset(); - return; - } + OutsideFence_ParseHeader(state, match); + return; } // Until we find a header that defines a profile, we don't care about anything under it. @@ -220,29 +202,77 @@ namespace Trash.Sonarr.ReleaseProfile return; } - var profile = results.GetOrCreate(state.ProfileName); + // These are often found in admonition (indented) blocks, so we check for it before we + // run the IsSkippableLine() check. if (line.ContainsIgnoreCase("include preferred")) { - profile.IncludePreferredWhenRenaming = !line.ContainsIgnoreCase("not"); + state.Profile.IncludePreferredWhenRenaming = !line.ContainsIgnoreCase("not"); Log.Debug(" - 'Include Preferred' found [Value: {IncludePreferredWhenRenaming}] [Line: {Line}]", - profile.IncludePreferredWhenRenaming, line); + state.Profile.IncludePreferredWhenRenaming, line); return; } - // Either we have a nested header or normal line at this point. - // We need to check if we're defining a new category. - var category = ParseCategory(line); - if (category != null) + 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.CurrentCategory = category.Value; - Log.Debug(" - Category Set [Name: {Category}] [Line: {Line}]", category, line); - // DO NOT RETURN HERE! - // The category and score are sometimes in the same sentence (line); continue processing the line! - // return; + 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); } @@ -254,50 +284,49 @@ namespace Trash.Sonarr.ReleaseProfile } } - private TermCategory? ParseCategory(string line) + private void ParseAndSetCategory(string line, ParserState state) { - foreach (var (category, regex) in _regexCategories) + var category = ParseCategory(line); + if (category == null) { - if (regex.Match(line).Success) - { - return category; - } + return; } - return null; - } + state.CurrentCategory.PushValue(category.Value, state.CurrentHeaderDepth); - private enum TermCategory - { - Required, - Ignored, - Preferred + Log.Debug(" - Category Set " + + "[Scope: {Scope}] " + + "[Name: {Category}] " + + "[Stack Size: {StackSize}] " + + "[Line: {Line}]", + category.Value, state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line); } - private class ParserState + private void ParseAndSetOptional(string line, ParserState state) { - public ParserState() + if (line.ContainsIgnoreCase("optional")) { - Reset(); - } + state.TermsAreOptional.PushValue(true, state.CurrentHeaderDepth); - public string? ProfileName { get; set; } - public int? Score { get; set; } - public TermCategory CurrentCategory { get; set; } - public int BracketDepth { get; set; } - public int CurrentHeaderDepth { get; set; } - public int LineNumber { get; set; } - - public bool IsValid => ProfileName != null && (CurrentCategory != TermCategory.Preferred || Score != null); + Log.Debug(" - Optional Set " + + "[Scope: {Scope}] " + + "[Stack Size: {StackSize}] " + + "[Line: {Line}]", + state.CurrentHeaderDepth, state.CurrentCategory.StackSize, line); + } + } - public void Reset() + private TermCategory? ParseCategory(string line) + { + foreach (var (category, regex) in _regexCategories) { - ProfileName = null; - Score = null; - CurrentCategory = TermCategory.Preferred; - BracketDepth = 0; - CurrentHeaderDepth = -1; + if (regex.Match(line).Success) + { + return category; + } } + + return null; } } } diff --git a/src/Trash/Sonarr/ReleaseProfile/ScopedState.cs b/src/Trash/Sonarr/ReleaseProfile/ScopedState.cs new file mode 100644 index 00000000..c4df2768 --- /dev/null +++ b/src/Trash/Sonarr/ReleaseProfile/ScopedState.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace Trash.Sonarr.ReleaseProfile +{ + public class ScopedState + { + private readonly T _defaultValue; + private readonly Stack _scopeStack = new(); + + public ScopedState(T defaultValue = default!) + { + _defaultValue = defaultValue; + } + + public T Value => _scopeStack.Count > 0 ? _scopeStack.Peek().Value : _defaultValue; + + public int? ActiveScope => _scopeStack.Count > 0 ? _scopeStack.Peek().Scope : null; + + public int StackSize => _scopeStack.Count; + + public void PushValue(T value, int scope) + { + if (_scopeStack.Count == 0 || _scopeStack.Peek().Scope < scope) + { + _scopeStack.Push(new Node(value, scope)); + } + else if (_scopeStack.Peek().Scope == scope) + { + _scopeStack.Peek().Value = value; + } + } + + public bool Reset(int scope) + { + if (_scopeStack.Count == 0) + { + return false; + } + + var prevCount = StackSize; + while (_scopeStack.Count > 0 && _scopeStack.Peek().Scope >= scope) + { + _scopeStack.Pop(); + } + + return prevCount != StackSize; + } + + private class Node + { + public Node(T value, int scope) + { + Value = value; + Scope = scope; + } + + public T Value { get; set; } + public int Scope { get; } + } + } +} diff --git a/src/Trash/Sonarr/ReleaseProfile/Utils.cs b/src/Trash/Sonarr/ReleaseProfile/Utils.cs index 06b85ec2..c3b9d956 100644 --- a/src/Trash/Sonarr/ReleaseProfile/Utils.cs +++ b/src/Trash/Sonarr/ReleaseProfile/Utils.cs @@ -25,6 +25,41 @@ namespace Trash.Sonarr.ReleaseProfile public static void PrintTermsAndScores(ProfileDataCollection profiles) { + static void PrintPreferredTerms(string title, IDictionary> dict) + { + if (dict.Count <= 0) + { + return; + } + + Console.WriteLine($" {title}:"); + foreach (var (score, terms) in dict) + { + foreach (var term in terms) + { + Console.WriteLine($" {score,-10} {term}"); + } + } + + Console.WriteLine(""); + } + + static void PrintTerms(string title, IReadOnlyCollection terms) + { + if (terms.Count == 0) + { + return; + } + + Console.WriteLine($" {title}:"); + foreach (var term in terms) + { + Console.WriteLine($" {term}"); + } + + Console.WriteLine(""); + } + Console.WriteLine(""); foreach (var (name, profile) in profiles) @@ -39,36 +74,12 @@ namespace Trash.Sonarr.ReleaseProfile Console.WriteLine(""); } - static void PrintTerms(string title, IReadOnlyCollection terms) - { - if (terms.Count == 0) - { - return; - } - - Console.WriteLine($" {title}:"); - foreach (var term in terms) - { - Console.WriteLine($" {term}"); - } - - Console.WriteLine(""); - } - PrintTerms("Must Contain", profile.Required); + PrintTerms("Must Contain (Optional)", profile.Optional.Required); PrintTerms("Must Not Contain", profile.Ignored); - - if (profile.Preferred.Count > 0) - { - Console.WriteLine(" Preferred:"); - foreach (var (score, terms) in profile.Preferred) - { - foreach (var term in terms) - { - Console.WriteLine($" {score,-10} {term}"); - } - } - } + PrintTerms("Must Not Contain (Optional)", profile.Optional.Ignored); + PrintPreferredTerms("Preferred", profile.Preferred); + PrintPreferredTerms("Preferred (Optional)", profile.Optional.Preferred); Console.WriteLine(""); } diff --git a/src/Trash/trash.yml b/src/Trash/trash.yml deleted file mode 100644 index a8f4db78..00000000 --- a/src/Trash/trash.yml +++ /dev/null @@ -1,45 +0,0 @@ -sonarr: - - base_url: http://localhost:8989 - api_key: f7e74ba6c80046e39e076a27af5a8444 - - # 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: - - type: anime - strict_negative_scores: true - tags: - - anime - - type: series - strict_negative_scores: false - tags: - - tv - -radarr: - - base_url: http://localhost:7878 - api_key: bf99da49d0b0488ea34e4464aa63a0e5 - - # Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie' - quality_definition: - type: movie - # A ratio that determines the preferred quality, when needed. Default is 1.0. - # Used to calculated the interpolated value between the min and max value for each table row. - preferred_ratio: 0.5 - - # Default quality profiles used if templates/singles/groups do not override it -# quality_profiles: -# - Movies -# -# templates: # Templates are taken FIRST -# - name: Remux-1080p -# quality_profiles: -# - Movies -# - Kids Movies -# custom_formats: # Singles and groups override values from the templates -# - name: Misc # Add the whole group (does nothing because in this case, `Remux-1080p` already adds it) -# - name: Misc/Multi # Multi exists in the template, but NO SCORE because the guide doesn't mention one. This adds in a score manually -# score: -100 - #custom_formats: - # - Movie Versions # Adds all CFs since this names a "group" / "collection" - # - Movie Versions.Hybrid # Add single CF