fix: sonarr devs broke the release profile api

The Sonarr developers made a backward-breaking API change resulting in
Trash Updater being unable to obtain, create, or update release
profiles. This fix keeps backward compatibility with the previous and
current schema at the cost of additional code complexity.

The specific breakage was in the Ignored and Required properties of the
Release Profile JSON schema. They were converted from string type to
array.

Offending change:
deed85d2f9/src/NzbDrone.Core/Datastore/Migration/162_release_profile_to_array.cs

Fixes #16
pull/47/head
Robert Dailey 3 years ago
parent b1651c89c6
commit 6f2d946902

@ -11,13 +11,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### FIXED
- libgit2sharp PDB is no longer required with trash.exe on Windows ([#15])
- Unexpected character error due to breaking change in Sonarr API ([#16])
[#15]: https://github.com/rcdailey/trash-updater/issues/15
[#16]: https://github.com/rcdailey/trash-updater/issues/16
## [1.6.3] - 2021-07-31
### FIXED
- Fix "assembly not found" error on startup related to LibGit2Sharp (Windows only). Note that this
introduces an additional file in the released ZIP files named `git2-6777db8.pdb`. This file must
be next to `trash.exe`. In the future, I plan to have this extra file removed so it's just a

@ -2,5 +2,7 @@
<ItemGroup>
<PackageReference Include="FluentValidation" />
<PackageReference Include="YamlDotNet" />
<PackageReference Include="Autofac" />
<PackageReference Include="Newtonsoft.Json" />
</ItemGroup>
</Project>

@ -1,5 +1,6 @@
<Project>
<ItemGroup>
<PackageReference Update="AutoMapper" Version="10.*" />
<PackageReference Update="Autofac" Version="6.*" />
<PackageReference Update="Autofac.Extensions.DependencyInjection" Version="7.*" />
<PackageReference Update="Autofac.Extras.AggregateService" Version="6.*" />
@ -21,6 +22,8 @@
<PackageReference Update="NUnit.Analyzers" Version="3.*" />
<PackageReference Update="NUnit3TestAdapter" Version="4.*" />
<PackageReference Update="Nerdbank.GitVersioning" Version="3.*" />
<PackageReference Update="Newtonsoft.Json" Version="13.*" />
<PackageReference Update="Newtonsoft.Json.Schema" Version="3.*" />
<PackageReference Update="Serilog" Version="2.*" />
<PackageReference Update="Serilog.Sinks.Console" Version="4.*" />
<PackageReference Update="Serilog.Sinks.File" Version="5.*" />
@ -28,6 +31,7 @@
<PackageReference Update="Serilog.Sinks.TestCorrelator" Version="3.*" />
<PackageReference Update="System.Data.HashFunction.FNV" Version="2.*" />
<PackageReference Update="System.IO.Abstractions" Version="13.*" />
<PackageReference Update="System.Reactive" Version="4.*" />
<PackageReference Update="YamlDotNet" Version="11.*" />
<PackageReference Update="morelinq" Version="3.*" />
</ItemGroup>

@ -18,11 +18,13 @@ namespace Trash.Command.Helpers
{
public abstract class ServiceCommand : ICommand, IServiceCommand
{
private readonly AutofacContractResolver _contractResolver;
private readonly LoggingLevelSwitch _loggingLevelSwitch;
private readonly ILogJanitor _logJanitor;
protected ServiceCommand(ILogger logger, LoggingLevelSwitch loggingLevelSwitch, ILogJanitor logJanitor)
protected ServiceCommand(
ILogger logger,
LoggingLevelSwitch loggingLevelSwitch,
ILogJanitor logJanitor)
{
_loggingLevelSwitch = loggingLevelSwitch;
_logJanitor = logJanitor;
@ -90,7 +92,7 @@ namespace Trash.Command.Helpers
Debug ? LogEventLevel.Debug : LogEventLevel.Information;
}
private static void SetupHttp()
private void SetupHttp()
{
FlurlHttp.Configure(settings =>
{

@ -14,6 +14,7 @@ using TrashLib.Config;
using TrashLib.Radarr;
using TrashLib.Radarr.Config;
using TrashLib.Sonarr;
using TrashLib.Startup;
using YamlDotNet.Serialization;
namespace Trash
@ -93,6 +94,8 @@ namespace Trash
builder.RegisterModule<SonarrAutofacModule>();
builder.RegisterModule<RadarrAutofacModule>();
builder.Register(_ => AutoMapperConfig.Setup()).SingleInstance();
// builder.RegisterSource(new AnyConcreteTypeNotAlreadyRegisteredSource());
return builder.Build();
}

@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using AutoMapper;
using FluentAssertions;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Serialization;
using NSubstitute;
using NUnit.Framework;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Api.Objects;
using TrashLib.Startup;
namespace TrashLib.Tests.Sonarr.Api
{
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrReleaseProfileCompatibilityHandlerTest
{
private class TestContext : IDisposable
{
private readonly JsonSerializerSettings _jsonSettings;
public TestContext()
{
_jsonSettings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver()
};
Mapper = AutoMapperConfig.Setup();
}
public IMapper Mapper { get; }
public void Dispose()
{
}
public string SerializeJson<T>(T obj)
{
return JsonConvert.SerializeObject(obj, _jsonSettings);
}
}
[Test]
public void Receive_v1_to_v2()
{
using var ctx = new TestContext();
var compat = Substitute.For<ISonarrCompatibility>();
var dataV1 = new SonarrReleaseProfileV1 {Ignored = "one,two,three"};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV1)));
_ = compat.DidNotReceive().ArraysNeededForReleaseProfileRequiredAndIgnored;
result.Should().BeEquivalentTo(new SonarrReleaseProfile
{
Ignored = new List<string> {"one", "two", "three"}
});
}
[Test]
public void Receive_v2_to_v2()
{
using var ctx = new TestContext();
var compat = Substitute.For<ISonarrCompatibility>();
var dataV2 = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForReceiving(JObject.Parse(ctx.SerializeJson(dataV2)));
_ = compat.DidNotReceive().ArraysNeededForReleaseProfileRequiredAndIgnored;
result.Should().BeEquivalentTo(dataV2);
}
[Test]
public void Send_v2_to_v1()
{
using var ctx = new TestContext();
var compat = Substitute.For<ISonarrCompatibility>();
compat.ArraysNeededForReleaseProfileRequiredAndIgnored.Returns(false);
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForSending(data);
result.Should().BeEquivalentTo(new SonarrReleaseProfileV1 {Ignored = "one,two,three"});
}
[Test]
public void Send_v2_to_v2()
{
using var ctx = new TestContext();
var compat = Substitute.For<ISonarrCompatibility>();
compat.ArraysNeededForReleaseProfileRequiredAndIgnored.Returns(true);
var data = new SonarrReleaseProfile {Ignored = new List<string> {"one", "two", "three"}};
var sut = new SonarrReleaseProfileCompatibilityHandler(compat, ctx.Mapper);
var result = sut.CompatibleReleaseProfileForSending(data);
result.Should().BeEquivalentTo(data);
}
}
}

@ -1,6 +1,7 @@
using NSubstitute;
using NUnit.Framework;
using Serilog;
using TrashLib.Sonarr;
using TrashLib.Sonarr.Api;
using TrashLib.Sonarr.Config;
using TrashLib.Sonarr.ReleaseProfile;
@ -16,6 +17,7 @@ namespace TrashLib.Tests.Sonarr
public IReleaseProfileGuideParser Parser { get; } = Substitute.For<IReleaseProfileGuideParser>();
public ISonarrApi Api { get; } = Substitute.For<ISonarrApi>();
public ILogger Logger { get; } = Substitute.For<ILogger>();
public ISonarrCompatibility Compatibility { get; } = Substitute.For<ISonarrCompatibility>();
}
[Test]
@ -23,7 +25,7 @@ namespace TrashLib.Tests.Sonarr
{
var context = new Context();
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api);
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, new SonarrConfiguration());
context.Parser.DidNotReceive().GetMarkdownData(Arg.Any<ReleaseProfileType>());
@ -40,7 +42,7 @@ namespace TrashLib.Tests.Sonarr
ReleaseProfiles = new[] {new ReleaseProfileConfig {Type = ReleaseProfileType.Anime}}
};
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api);
var logic = new ReleaseProfileUpdater(context.Logger, context.Parser, context.Api, context.Compatibility);
logic.Process(false, config);
context.Parser.Received().ParseMarkdown(config.ReleaseProfiles[0], "theMarkdown");

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks;
using TrashLib.Sonarr.Api.Objects;
@ -7,7 +6,6 @@ namespace TrashLib.Sonarr.Api
{
public interface ISonarrApi
{
Task<Version> GetVersion();
Task<IList<SonarrTag>> GetTags();
Task<SonarrTag> CreateTag(string tag);
Task<IList<SonarrReleaseProfile>> GetReleaseProfiles();

@ -0,0 +1,11 @@
using Newtonsoft.Json.Linq;
using TrashLib.Sonarr.Api.Objects;
namespace TrashLib.Sonarr.Api
{
public interface ISonarrReleaseProfileCompatibilityHandler
{
object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile);
SonarrReleaseProfile CompatibleReleaseProfileForReceiving(JObject profile);
}
}

@ -0,0 +1,27 @@
using System;
using System.Linq;
using AutoMapper;
using JetBrains.Annotations;
using TrashLib.Sonarr.Api.Objects;
namespace TrashLib.Sonarr.Api.Mappings
{
[UsedImplicitly]
public class SonarrApiObjectMappingProfile : Profile
{
public SonarrApiObjectMappingProfile()
{
CreateMap<SonarrReleaseProfileV1, SonarrReleaseProfile>()
.ForMember(d => d.Ignored, x => x.MapFrom(
s => s.Ignored.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()))
.ForMember(d => d.Required, x => x.MapFrom(
s => s.Required.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()));
CreateMap<SonarrReleaseProfile, SonarrReleaseProfileV1>()
.ForMember(d => d.Ignored, x => x.MapFrom(
s => string.Join(',', s.Ignored)))
.ForMember(d => d.Required, x => x.MapFrom(
s => string.Join(',', s.Required)));
}
}
}

@ -20,8 +20,12 @@ namespace TrashLib.Sonarr.Api.Objects
public int Score { get; set; }
}
// Retained for supporting versions of Sonarr prior to v3.0.6.1355
// Offending change is here:
// https://github.com/Sonarr/Sonarr/blob/deed85d2f9147e6180014507ef4f5af3695b0c61/src/NzbDrone.Core/Datastore/Migration/162_release_profile_to_array.cs
// The Ignored and Required JSON properties were converted from string -> array in a backward-breaking way.
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class SonarrReleaseProfile
public class SonarrReleaseProfileV1
{
public int Id { get; set; }
public bool Enabled { get; set; }
@ -33,4 +37,18 @@ namespace TrashLib.Sonarr.Api.Objects
public int IndexerId { get; set; }
public IReadOnlyCollection<int> Tags { get; set; } = new List<int>();
}
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class SonarrReleaseProfile
{
public int Id { get; set; }
public bool Enabled { get; set; }
public string Name { get; set; } = "";
public IReadOnlyCollection<string> Required { get; set; } = new List<string>();
public IReadOnlyCollection<string> Ignored { get; set; } = new List<string>();
public IReadOnlyCollection<SonarrPreferredTerm> Preferred { get; set; } = new List<SonarrPreferredTerm>();
public bool IncludePreferredWhenRenaming { get; set; }
public int IndexerId { get; set; }
public IReadOnlyCollection<int> Tags { get; set; } = new List<int>();
}
}

@ -1,8 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Flurl;
using Flurl.Http;
using Newtonsoft.Json.Linq;
using TrashLib.Config;
using TrashLib.Sonarr.Api.Objects;
@ -10,19 +11,13 @@ namespace TrashLib.Sonarr.Api
{
public class SonarrApi : ISonarrApi
{
private readonly ISonarrReleaseProfileCompatibilityHandler _profileHandler;
private readonly IServerInfo _serverInfo;
public SonarrApi(IServerInfo serverInfo)
public SonarrApi(IServerInfo serverInfo, ISonarrReleaseProfileCompatibilityHandler profileHandler)
{
_serverInfo = serverInfo;
}
public async Task<Version> GetVersion()
{
dynamic data = await BaseUrl()
.AppendPathSegment("system/status")
.GetJsonAsync();
return new Version(data.version);
_profileHandler = profileHandler;
}
public async Task<IList<SonarrTag>> GetTags()
@ -42,24 +37,30 @@ namespace TrashLib.Sonarr.Api
public async Task<IList<SonarrReleaseProfile>> GetReleaseProfiles()
{
return await BaseUrl()
var response = await BaseUrl()
.AppendPathSegment("releaseprofile")
.GetJsonAsync<List<SonarrReleaseProfile>>();
.GetJsonAsync<List<JObject>>();
return response
.Select(_profileHandler.CompatibleReleaseProfileForReceiving)
.ToList();
}
public async Task UpdateReleaseProfile(SonarrReleaseProfile profileToUpdate)
{
await BaseUrl()
.AppendPathSegment($"releaseprofile/{profileToUpdate.Id}")
.PutJsonAsync(profileToUpdate);
.PutJsonAsync(_profileHandler.CompatibleReleaseProfileForSending(profileToUpdate));
}
public async Task<SonarrReleaseProfile> CreateReleaseProfile(SonarrReleaseProfile newProfile)
{
return await BaseUrl()
var response = await BaseUrl()
.AppendPathSegment("releaseprofile")
.PostJsonAsync(newProfile)
.ReceiveJson<SonarrReleaseProfile>();
.PostJsonAsync(_profileHandler.CompatibleReleaseProfileForSending(newProfile))
.ReceiveJson<JObject>();
return _profileHandler.CompatibleReleaseProfileForReceiving(response);
}
public async Task<IReadOnlyCollection<SonarrQualityDefinitionItem>> GetQualityDefinition()

@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.IO;
using AutoMapper;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Schema;
using Newtonsoft.Json.Schema.Generation;
using Newtonsoft.Json.Serialization;
using Serilog;
using TrashLib.Sonarr.Api.Objects;
namespace TrashLib.Sonarr.Api
{
public class SonarrReleaseProfileCompatibilityHandler : ISonarrReleaseProfileCompatibilityHandler
{
private readonly ISonarrCompatibility _compatibility;
private readonly JSchemaGenerator _generator;
private readonly IMapper _mapper;
public SonarrReleaseProfileCompatibilityHandler(
ISonarrCompatibility compatibility,
IMapper mapper)
{
_compatibility = compatibility;
_mapper = mapper;
_generator = new JSchemaGenerator
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
DefaultRequired = Required.Default
};
}
public object CompatibleReleaseProfileForSending(SonarrReleaseProfile profile)
{
return _compatibility.ArraysNeededForReleaseProfileRequiredAndIgnored
? profile
: _mapper.Map<SonarrReleaseProfileV1>(profile);
}
public SonarrReleaseProfile CompatibleReleaseProfileForReceiving(JObject profile)
{
JSchema? schema;
IList<string>? errorMessages;
schema = _generator.Generate(typeof(SonarrReleaseProfile));
if (profile.IsValid(schema, out errorMessages))
{
return profile.ToObject<SonarrReleaseProfile>()
?? throw new InvalidDataException("SonarrReleaseProfile V2 parsing failed");
}
Log.Debug("SonarrReleaseProfile is not a match for V2, proceeding to V1: {Reasons}", errorMessages);
schema = _generator.Generate(typeof(SonarrReleaseProfileV1));
if (profile.IsValid(schema, out errorMessages))
{
// This will throw if there's an issue during mapping.
return _mapper.Map<SonarrReleaseProfile>(profile.ToObject<SonarrReleaseProfileV1>());
}
throw new InvalidDataException(
$"SonarrReleaseProfile expected, but no supported schema detected: {errorMessages}");
}
}
}

@ -0,0 +1,10 @@
namespace TrashLib.Sonarr
{
public interface ISonarrCompatibility
{
bool SupportsNamedReleaseProfiles { get; }
bool ArraysNeededForReleaseProfileRequiredAndIgnored { get; }
string InformationalVersion { get; }
string MinimumVersion { get; }
}
}

@ -13,13 +13,19 @@ namespace TrashLib.Sonarr.ReleaseProfile
internal class ReleaseProfileUpdater : IReleaseProfileUpdater
{
private readonly ISonarrApi _api;
private readonly ISonarrCompatibility _compatibility;
private readonly IReleaseProfileGuideParser _parser;
public ReleaseProfileUpdater(ILogger logger, IReleaseProfileGuideParser parser, ISonarrApi api)
public ReleaseProfileUpdater(
ILogger logger,
IReleaseProfileGuideParser parser,
ISonarrApi api,
ISonarrCompatibility compatibility)
{
Log = logger;
_parser = parser;
_api = api;
_compatibility = compatibility;
}
private ILogger Log { get; }
@ -47,18 +53,13 @@ namespace TrashLib.Sonarr.ReleaseProfile
}
}
private async Task DoVersionEnforcement()
private void DoVersionEnforcement()
{
// Since this script requires a specific version of v3 Sonarr that implements name support for
// release profiles, we perform that version check here and bail out if it does not meet a minimum
// required version.
var minimumVersion = new Version("3.0.4.1098");
var version = await _api.GetVersion();
if (version < minimumVersion)
if (!_compatibility.SupportsNamedReleaseProfiles)
{
throw new VersionException(
$"Your Sonarr version {version} does not meet the minimum " +
$"required version of {minimumVersion} to use this program");
$"Your Sonarr version {_compatibility.InformationalVersion} does not meet the minimum " +
$"required version of {_compatibility.MinimumVersion} to use this program");
}
}
@ -92,8 +93,8 @@ namespace TrashLib.Sonarr.ReleaseProfile
.SelectMany(kvp => kvp.Value.Select(term => new SonarrPreferredTerm(kvp.Key, term)))
.ToList();
profileToUpdate.Ignored = string.Join(',', profile.Ignored);
profileToUpdate.Required = string.Join(',', profile.Required);
profileToUpdate.Ignored = profile.Ignored.ToList(); //string.Join(',', profile.Ignored);
profileToUpdate.Required = profile.Required.ToList(); //string.Join(',', profile.Required);
// Null means the guide didn't specify a value for this, so we leave the existing setting intact.
if (profile.IncludePreferredWhenRenaming != null)
@ -127,7 +128,7 @@ namespace TrashLib.Sonarr.ReleaseProfile
private async Task ProcessReleaseProfiles(IDictionary<string, ProfileData> profiles,
ReleaseProfileConfig config)
{
await DoVersionEnforcement();
DoVersionEnforcement();
List<int> tagIds = new();

@ -13,9 +13,15 @@ namespace TrashLib.Sonarr
builder.RegisterType<SonarrApi>().As<ISonarrApi>();
builder.RegisterType<SonarrValidationMessages>().As<ISonarrValidationMessages>();
builder.RegisterType<SonarrCompatibility>()
.As<ISonarrCompatibility>()
.SingleInstance();
// Release Profile Support
builder.RegisterType<ReleaseProfileUpdater>().As<IReleaseProfileUpdater>();
builder.RegisterType<ReleaseProfileGuideParser>().As<IReleaseProfileGuideParser>();
builder.RegisterType<SonarrReleaseProfileCompatibilityHandler>()
.As<ISonarrReleaseProfileCompatibilityHandler>();
// Quality Definition Support
builder.RegisterType<SonarrQualityDefinitionUpdater>().As<ISonarrQualityDefinitionUpdater>();

@ -0,0 +1,38 @@
using System;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Flurl;
using Flurl.Http;
using TrashLib.Config;
namespace TrashLib.Sonarr
{
public class SonarrCompatibility : ISonarrCompatibility
{
private Version _version = new();
public SonarrCompatibility(IServerInfo serverInfo)
{
var task = serverInfo.BuildUrl()
.AppendPathSegment("system/status")
.GetJsonAsync();
task.ToObservable()
.Select(x => new Version(x.version))
.Subscribe(x => _version = x);
}
public bool SupportsNamedReleaseProfiles =>
_version >= new Version(MinimumVersion);
// Background: Issue #16 filed which points to a backward-breaking API
// change made in Sonarr at commit [deed85d2f].
//
// [deed85d2f]: https://github.com/Sonarr/Sonarr/commit/deed85d2f9147e6180014507ef4f5af3695b0c61
public bool ArraysNeededForReleaseProfileRequiredAndIgnored =>
_version >= new Version("3.0.6.1355");
public string InformationalVersion => _version.ToString();
public string MinimumVersion => "3.0.4.1098";
}
}

@ -0,0 +1,22 @@
using AutoMapper;
namespace TrashLib.Startup
{
public static class AutoMapperConfig
{
public static IMapper Setup()
{
// todo: consider using AutoMapper.Contrib.Autofac.DependencyInjection
var mapperConfig = new MapperConfiguration(cfg =>
{
cfg.AddMaps(typeof(AutoMapperConfig));
});
#if DEBUG
mapperConfig.AssertConfigurationIsValid();
#endif
return mapperConfig.CreateMapper();
}
}
}

@ -1,14 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="AutoMapper" />
<PackageReference Include="Autofac" />
<PackageReference Include="Autofac.Extras.AggregateService" />
<PackageReference Include="FluentValidation" />
<PackageReference Include="Flurl" />
<PackageReference Include="Flurl.Http" />
<PackageReference Include="LibGit2Sharp" />
<PackageReference Include="Newtonsoft.Json.Schema" />
<PackageReference Include="Serilog" />
<PackageReference Include="System.Data.HashFunction.FNV" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="System.Reactive" />
<PackageReference Include="morelinq" />
</ItemGroup>

Loading…
Cancel
Save