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