refactor: Make tags pipeline generic

pull/231/head
Robert Dailey 1 year ago
parent 532b954456
commit 5c27c6bf56

@ -78,7 +78,9 @@ public static class CompositionRoot
builder.RegisterGeneric(typeof(GenericPipelinePhases<>)); builder.RegisterGeneric(typeof(GenericPipelinePhases<>));
builder.RegisterTypes( builder.RegisterTypes(
typeof(TagSyncPipeline), // ORDER HERE IS IMPORTANT!
// There are indirect dependencies between pipelines.
typeof(GenericSyncPipeline<TagPipelineContext>),
typeof(CustomFormatSyncPipeline), typeof(CustomFormatSyncPipeline),
typeof(QualityProfileSyncPipeline), typeof(QualityProfileSyncPipeline),
typeof(QualitySizeSyncPipeline), typeof(QualitySizeSyncPipeline),

@ -1,6 +1,7 @@
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public class GenericPipelinePhases<TContext> public class GenericPipelinePhases<TContext>
where TContext : IPipelineContext
{ {
public required IConfigPipelinePhase<TContext> ConfigPhase { get; init; } public required IConfigPipelinePhase<TContext> ConfigPhase { get; init; }
public required ILogPipelinePhase<TContext> LogPhase { get; init; } public required ILogPipelinePhase<TContext> LogPhase { get; init; }

@ -3,12 +3,17 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public class GenericSyncPipeline<TContext>(GenericPipelinePhases<TContext> phases) : ISyncPipeline public class GenericSyncPipeline<TContext>(ILogger log, GenericPipelinePhases<TContext> phases) : ISyncPipeline
where TContext : new() where TContext : IPipelineContext, new()
{ {
public async Task Execute(ISyncSettings settings, IServiceConfiguration config) public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{ {
var context = new TContext(); var context = new TContext();
if (!context.SupportedServiceTypes.Contains(config.ServiceType))
{
log.Debug("Skipping {Description} because it does not support service type {Service}",
context.PipelineDescription, config.ServiceType);
}
await phases.ConfigPhase.Execute(context, config); await phases.ConfigPhase.Execute(context, config);
if (phases.LogPhase.LogConfigPhaseAndExitIfNeeded(context)) if (phases.LogPhase.LogConfigPhaseAndExitIfNeeded(context))

@ -3,6 +3,7 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface IApiFetchPipelinePhase<in TContext> public interface IApiFetchPipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
Task Execute(TContext context, IServiceConfiguration config); Task Execute(TContext context, IServiceConfiguration config);
} }

@ -3,6 +3,7 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface IApiPersistencePipelinePhase<in TContext> public interface IApiPersistencePipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
Task Execute(TContext context, IServiceConfiguration config); Task Execute(TContext context, IServiceConfiguration config);
} }

@ -3,6 +3,7 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface IConfigPipelinePhase<in TContext> public interface IConfigPipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
Task Execute(TContext context, IServiceConfiguration config); Task Execute(TContext context, IServiceConfiguration config);
} }

@ -1,7 +1,8 @@
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface ILogPipelinePhase<in TContext> public interface ILogPipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
bool LogConfigPhaseAndExitIfNeeded(TContext context); bool LogConfigPhaseAndExitIfNeeded(TContext context);
void LogPersistenceResults(TContext context); void LogPersistenceResults(TContext context);
} }

@ -0,0 +1,9 @@
using Recyclarr.Common;
namespace Recyclarr.Cli.Pipelines.Generic;
public interface IPipelineContext
{
string PipelineDescription { get; }
IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; }
}

@ -1,6 +1,7 @@
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface IPreviewPipelinePhase<in TContext> public interface IPreviewPipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
void Execute(TContext context); void Execute(TContext context);
} }

@ -1,6 +1,7 @@
namespace Recyclarr.Cli.Pipelines.Generic; namespace Recyclarr.Cli.Pipelines.Generic;
public interface ITransactionPipelinePhase<in TContext> public interface ITransactionPipelinePhase<in TContext>
where TContext : IPipelineContext
{ {
void Execute(TContext context); void Execute(TContext context);
} }

@ -1,10 +1,19 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases; using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.Common;
using Recyclarr.ServarrApi.MediaNaming; using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming; namespace Recyclarr.Cli.Pipelines.MediaNaming;
public class MediaNamingPipelineContext public class MediaNamingPipelineContext : IPipelineContext
{ {
public string PipelineDescription => "Media Naming Pipeline";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } = new[]
{
SupportedServices.Sonarr,
SupportedServices.Radarr
};
public ProcessedNamingConfig ConfigOutput { get; set; } = default!; public ProcessedNamingConfig ConfigOutput { get; set; } = default!;
public MediaNamingDto ApiFetchOutput { get; set; } = default!; public MediaNamingDto ApiFetchOutput { get; set; } = default!;
public MediaNamingDto TransactionOutput { get; set; } = default!; public MediaNamingDto TransactionOutput { get; set; } = default!;

@ -1,15 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Tag; using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagApiFetchPhase(ISonarrTagApiService api, ServiceTagCache cache) public class TagApiFetchPhase(ISonarrTagApiService api, ServiceTagCache cache)
: IApiFetchPipelinePhase<TagPipelineContext>
{ {
public async Task<IList<SonarrTag>> Execute(IServiceConfiguration config) public async Task Execute(TagPipelineContext context, IServiceConfiguration config)
{ {
var tags = await api.GetTags(config); var tags = await api.GetTags(config);
cache.Clear();
cache.AddTags(tags); cache.AddTags(tags);
return tags; context.ApiFetchOutput = tags;
} }
} }

@ -1,17 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Tag; using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagApiPersistencePhase( public class TagApiPersistencePhase(ILogger log, ServiceTagCache cache, ISonarrTagApiService api)
ILogger log, : IApiPersistencePipelinePhase<TagPipelineContext>
ServiceTagCache cache,
ISonarrTagApiService api)
{ {
public async Task Execute(IServiceConfiguration config, IEnumerable<string> tagsToCreate) public async Task Execute(TagPipelineContext context, IServiceConfiguration config)
{ {
var createdTags = new List<SonarrTag>(); var createdTags = new List<SonarrTag>();
foreach (var tag in tagsToCreate) foreach (var tag in context.TransactionOutput)
{ {
log.Debug("Creating Tag: {Tag}", tag); log.Debug("Creating Tag: {Tag}", tag);
createdTags.Add(await api.CreateTag(config, tag)); createdTags.Add(await api.CreateTag(config, tag));

@ -1,18 +1,17 @@
using System.Diagnostics.CodeAnalysis; using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagConfigPhase public class TagConfigPhase : IConfigPipelinePhase<TagPipelineContext>
{ {
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = public Task Execute(TagPipelineContext context, IServiceConfiguration config)
"This non-static method establishes a pattern that will eventually become an interface")]
public IList<string>? Execute(SonarrConfiguration config)
{ {
return config.ReleaseProfiles context.ConfigOutput = ((SonarrConfiguration) config).ReleaseProfiles
.SelectMany(x => x.Tags) .SelectMany(x => x.Tags)
.Distinct() .Distinct()
.ToListOrNull(); .ToList();
return Task.CompletedTask;
} }
} }

@ -0,0 +1,31 @@
using Recyclarr.Cli.Pipelines.Generic;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagLogPhase(ILogger log) : ILogPipelinePhase<TagPipelineContext>
{
public bool LogConfigPhaseAndExitIfNeeded(TagPipelineContext context)
{
if (!context.ConfigOutput.Any())
{
log.Debug("No tags to process");
return true;
}
return false;
}
public void LogPersistenceResults(TagPipelineContext context)
{
if (context.TransactionOutput.Any())
{
log.Information("Created {Count} Tags: {Tags}",
context.TransactionOutput.Count,
context.TransactionOutput);
}
else
{
log.Information("All tags are up to date!");
}
}
}

@ -1,12 +1,15 @@
using Castle.Core.Internal; using Castle.Core.Internal;
using Recyclarr.Cli.Pipelines.Generic;
using Spectre.Console; using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagPreviewPhase(IAnsiConsole console) public class TagPreviewPhase(IAnsiConsole console) : IPreviewPipelinePhase<TagPipelineContext>
{ {
public void Execute(IReadOnlyList<string> tagsToCreate) public void Execute(TagPipelineContext context)
{ {
var tagsToCreate = context.TransactionOutput;
if (tagsToCreate.IsNullOrEmpty()) if (tagsToCreate.IsNullOrEmpty())
{ {
console.WriteLine(); console.WriteLine();

@ -1,19 +1,16 @@
using System.Diagnostics.CodeAnalysis; using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions; using Recyclarr.Common.Extensions;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases; namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagTransactionPhase public class TagTransactionPhase : ITransactionPipelinePhase<TagPipelineContext>
{ {
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = public void Execute(TagPipelineContext context)
"This non-static method establishes a pattern that will eventually become an interface")]
public IList<string> Execute(IList<string> configTags, IList<SonarrTag> serviceTags)
{ {
// List of tags in config that do not already exist in the service. The goal is to figure out which tags need to // List of tags in config that do not already exist in the service. The goal is to figure out which tags need to
// be created. // be created.
return configTags context.TransactionOutput = context.ConfigOutput
.Where(ct => serviceTags.All(st => !st.Label.EqualsIgnoreCase(ct))) .Where(ct => context.ApiFetchOutput.All(st => !st.Label.EqualsIgnoreCase(ct)))
.ToList(); .ToList();
} }
} }

@ -0,0 +1,21 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags;
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification =
"Context objects are similar to DTOs; for usability we want to assign not append")]
public class TagPipelineContext : IPipelineContext
{
public string PipelineDescription => "Tag Pipeline";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } = new[]
{
SupportedServices.Sonarr
};
public IList<string> ConfigOutput { get; set; } = default!;
public IList<SonarrTag> ApiFetchOutput { get; set; } = default!;
public IList<string> TransactionOutput { get; set; } = default!;
}

@ -1,46 +0,0 @@
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Tags;
public interface ITagPipelinePhases
{
TagConfigPhase ConfigPhase { get; }
Lazy<TagPreviewPhase> PreviewPhase { get; }
TagApiFetchPhase ApiFetchPhase { get; }
TagTransactionPhase TransactionPhase { get; }
TagApiPersistencePhase ApiPersistencePhase { get; }
}
public class TagSyncPipeline(
ILogger log,
ITagPipelinePhases phases) : ISyncPipeline
{
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
if (config is not SonarrConfiguration sonarrConfig)
{
log.Debug("Skipping tag pipeline because {Instance} is not a Sonarr config", config.InstanceName);
return;
}
var tags = phases.ConfigPhase.Execute(sonarrConfig);
if (tags is null)
{
log.Debug("No tags to process");
return;
}
var serviceData = await phases.ApiFetchPhase.Execute(config);
var transactions = phases.TransactionPhase.Execute(tags, serviceData);
if (settings.Preview)
{
phases.PreviewPhase.Value.Execute(transactions.AsReadOnly());
return;
}
await phases.ApiPersistencePhase.Execute(config, transactions);
}
}

@ -1,7 +1,5 @@
using Autofac; using Autofac;
using Autofac.Extras.AggregateService;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases; using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags; namespace Recyclarr.Cli.Pipelines.Tags;
@ -15,13 +13,13 @@ public class TagsAutofacModule : Module
.AsSelf() .AsSelf()
.InstancePerLifetimeScope(); .InstancePerLifetimeScope();
builder.RegisterType<SonarrTagApiService>().As<ISonarrTagApiService>(); builder.RegisterTypes(
typeof(TagConfigPhase),
builder.RegisterAggregateService<ITagPipelinePhases>(); typeof(TagPreviewPhase),
builder.RegisterType<TagConfigPhase>(); typeof(TagApiFetchPhase),
builder.RegisterType<TagPreviewPhase>(); typeof(TagTransactionPhase),
builder.RegisterType<TagApiFetchPhase>(); typeof(TagApiPersistencePhase),
builder.RegisterType<TagTransactionPhase>(); typeof(TagLogPhase))
builder.RegisterType<TagApiPersistencePhase>(); .AsImplementedInterfaces();
} }
} }

@ -6,6 +6,7 @@ using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.ServarrApi.QualityDefinition; using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.ServarrApi.QualityProfile; using Recyclarr.ServarrApi.QualityProfile;
using Recyclarr.ServarrApi.System; using Recyclarr.ServarrApi.System;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.ServarrApi; namespace Recyclarr.ServarrApi;
@ -24,5 +25,6 @@ public class ApiServicesAutofacModule : Module
builder.RegisterType<CustomFormatApiService>().As<ICustomFormatApiService>(); builder.RegisterType<CustomFormatApiService>().As<ICustomFormatApiService>();
builder.RegisterType<QualityDefinitionApiService>().As<IQualityDefinitionApiService>(); builder.RegisterType<QualityDefinitionApiService>().As<IQualityDefinitionApiService>();
builder.RegisterType<MediaNamingApiService>().As<IMediaNamingApiService>(); builder.RegisterType<MediaNamingApiService>().As<IMediaNamingApiService>();
builder.RegisterType<SonarrTagApiService>().As<ISonarrTagApiService>();
} }
} }

@ -8,9 +8,10 @@ namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
public class TagApiFetchPhaseTest public class TagApiFetchPhaseTest
{ {
[Test, AutoMockData] [Test, AutoMockData]
public async Task Cache_is_cleared_and_updated( public async Task Cache_is_updated(
[Frozen] ISonarrTagApiService api, [Frozen] ISonarrTagApiService api,
[Frozen] ServiceTagCache cache, [Frozen] ServiceTagCache cache,
TagPipelineContext context,
TagApiFetchPhase sut) TagApiFetchPhase sut)
{ {
var expectedData = new[] var expectedData = new[]
@ -22,13 +23,7 @@ public class TagApiFetchPhaseTest
api.GetTags(default!).ReturnsForAnyArgs(expectedData); api.GetTags(default!).ReturnsForAnyArgs(expectedData);
cache.AddTags(new[] await sut.Execute(context, default!);
{
new SonarrTag {Id = 1},
new SonarrTag {Id = 2}
});
await sut.Execute(default!);
cache.Tags.Should().BeEquivalentTo(expectedData); cache.Tags.Should().BeEquivalentTo(expectedData);
} }
} }

@ -21,12 +21,15 @@ public class TagApiPersistencePhaseTest
}); });
var config = Substitute.For<IServiceConfiguration>(); var config = Substitute.For<IServiceConfiguration>();
var tagsToCreate = new[] {"three", "four"}; var context = new TagPipelineContext
{
TransactionOutput = new[] {"three", "four"}
};
api.CreateTag(config, "three").Returns(new SonarrTag {Id = 3}); api.CreateTag(config, "three").Returns(new SonarrTag {Id = 3});
api.CreateTag(config, "four").Returns(new SonarrTag {Id = 4}); api.CreateTag(config, "four").Returns(new SonarrTag {Id = 4});
await sut.Execute(config, tagsToCreate); await sut.Execute(context, config);
cache.Tags.Should().BeEquivalentTo(new[] cache.Tags.Should().BeEquivalentTo(new[]
{ {

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases; using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary; using Recyclarr.Tests.TestLibrary;
@ -8,19 +9,20 @@ namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
public class TagConfigPhaseTest public class TagConfigPhaseTest
{ {
[Test, AutoMockData] [Test, AutoMockData]
public void Return_null_when_list_empty(TagConfigPhase sut) public async Task Output_empty_when_config_has_no_tags(TagConfigPhase sut)
{ {
var context = new TagPipelineContext();
var config = NewConfig.Sonarr() with var config = NewConfig.Sonarr() with
{ {
ReleaseProfiles = Array.Empty<ReleaseProfileConfig>() ReleaseProfiles = Array.Empty<ReleaseProfileConfig>()
}; };
var result = sut.Execute(config); await sut.Execute(context, config);
result.Should().BeNull(); context.ConfigOutput.Should().BeEmpty();
} }
[Test, AutoMockData] [Test, AutoMockData]
public void Return_tags(TagConfigPhase sut) public void Output_not_empty_when_config_has_tags(TagConfigPhase sut)
{ {
var config = NewConfig.Sonarr() with var config = NewConfig.Sonarr() with
{ {
@ -33,7 +35,8 @@ public class TagConfigPhaseTest
} }
}; };
var result = sut.Execute(config); var context = new TagPipelineContext();
result.Should().BeEquivalentTo(config.ReleaseProfiles[0].Tags); sut.Execute(context, config);
context.ConfigOutput.Should().BeEquivalentTo(config.ReleaseProfiles[0].Tags);
} }
} }

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases; using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.ServarrApi.Tag; using Recyclarr.ServarrApi.Tag;
@ -9,41 +10,50 @@ public class TagTransactionPhaseTest
[Test, AutoMockData] [Test, AutoMockData]
public void Return_tags_in_config_that_do_not_exist_in_service(TagTransactionPhase sut) public void Return_tags_in_config_that_do_not_exist_in_service(TagTransactionPhase sut)
{ {
var configTags = new[] {"one", "two", "three"}; var context = new TagPipelineContext
var serviceTags = new[]
{ {
new SonarrTag {Label = "three"}, ConfigOutput = new[] {"one", "two", "three"},
new SonarrTag {Label = "four"} ApiFetchOutput = new[]
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
}
}; };
var result = sut.Execute(configTags, serviceTags); sut.Execute(context);
result.Should().BeEquivalentTo("one", "two"); context.TransactionOutput.Should().BeEquivalentTo("one", "two");
} }
[Test, AutoMockData] [Test, AutoMockData]
public void Return_all_tags_if_none_exist(TagTransactionPhase sut) public void Return_all_tags_if_none_exist(TagTransactionPhase sut)
{ {
var configTags = new[] {"one", "two", "three"}; var context = new TagPipelineContext
var serviceTags = Array.Empty<SonarrTag>(); {
ConfigOutput = new[] {"one", "two", "three"},
ApiFetchOutput = Array.Empty<SonarrTag>()
};
var result = sut.Execute(configTags, serviceTags); sut.Execute(context);
result.Should().BeEquivalentTo("one", "two", "three"); context.TransactionOutput.Should().BeEquivalentTo("one", "two", "three");
} }
[Test, AutoMockData] [Test, AutoMockData]
public void No_tags_returned_if_all_exist(TagTransactionPhase sut) public void No_tags_returned_if_all_exist(TagTransactionPhase sut)
{ {
var configTags = Array.Empty<string>(); var context = new TagPipelineContext
var serviceTags = new[]
{ {
new SonarrTag {Label = "three"}, ConfigOutput = Array.Empty<string>(),
new SonarrTag {Label = "four"} ApiFetchOutput = new[]
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
}
}; };
var result = sut.Execute(configTags, serviceTags); sut.Execute(context);
result.Should().BeEmpty(); context.TransactionOutput.Should().BeEmpty();
} }
} }

Loading…
Cancel
Save