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)
pull/5/head
Robert Dailey 3 years ago
parent b27f80268a
commit af97311899

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

@ -17,6 +17,7 @@ using YamlDotNet.Serialization.ObjectFactories;
namespace Trash.Tests.Config
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigurationLoaderTest
{
private TextReader GetResourceData(string file)

@ -6,6 +6,7 @@ using Trash.Extensions;
namespace Trash.Tests.Extensions
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class DictionaryExtensionsTest
{
private class MySampleValue

@ -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<ReleaseProfileParserTest> TestData { get; } = new();
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")
.WhichValue.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")
.WhichValue.Should().BeEquivalentTo(new
{
Ignored = new List<string> {"abc"},
Required = new List<string> {"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<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()
{
@ -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<int, List<string>>
{
{100, new List<string> {"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<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);
}
}
}

@ -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<int>(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<int>(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<int>(50);
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_ResetAfterScope_ReturnDefault()
{
var state = new ScopedState<int>(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<int>(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<int>(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<int>(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<int>(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<int>(50);
state.PushValue(100, 1);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
}
}

@ -8,6 +8,7 @@ using Trash.Sonarr.ReleaseProfile;
namespace Trash.Tests.Sonarr
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ReleaseProfileUpdaterTest
{
private class Context

@ -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<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 List<string> IgnoredTerms
=> TermsAreOptional.Value ? Profile.Optional.Ignored : Profile.Ignored;
public List<string> RequiredTerms
=> TermsAreOptional.Value ? Profile.Optional.Required : Profile.Required;
public Dictionary<int, List<string>> 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);
}
}
}
}

@ -2,16 +2,25 @@
namespace Trash.Sonarr.ReleaseProfile
{
public class ProfileDataOptional
{
public List<string> Required { get; init; } = new();
public List<string> Ignored { get; init; } = new();
public Dictionary<int, List<string>> Preferred { get; init; } = new();
}
public class ProfileData
{
public List<string> Required { get; } = new();
public List<string> Ignored { get; } = new();
public Dictionary<int, List<string>> Preferred { get; } = new();
public List<string> Required { get; init; } = new();
public List<string> Ignored { get; init; } = new();
public Dictionary<int, List<string>> 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();
}
}

@ -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<string, ProfileData> ParseMarkdown(ReleaseProfileConfig config, string markdown)
{
var results = new Dictionary<string, ProfileData>();
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<string, ProfileData> 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<string, ProfileData> 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;
}
}
}

@ -0,0 +1,61 @@
using System.Collections.Generic;
namespace Trash.Sonarr.ReleaseProfile
{
public class ScopedState<T>
{
private readonly T _defaultValue;
private readonly Stack<Node> _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; }
}
}
}

@ -25,6 +25,41 @@ namespace Trash.Sonarr.ReleaseProfile
public static void PrintTermsAndScores(ProfileDataCollection profiles)
{
static void PrintPreferredTerms(string title, IDictionary<int, List<string>> 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<string> 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<string> 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("");
}

@ -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
Loading…
Cancel
Save