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.RegisterTypes(
typeof(TagSyncPipeline),
// ORDER HERE IS IMPORTANT!
// There are indirect dependencies between pipelines.
typeof(GenericSyncPipeline<TagPipelineContext>),
typeof(CustomFormatSyncPipeline),
typeof(QualityProfileSyncPipeline),
typeof(QualitySizeSyncPipeline),

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

@ -3,12 +3,17 @@ using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Generic;
public class GenericSyncPipeline<TContext>(GenericPipelinePhases<TContext> phases) : ISyncPipeline
where TContext : new()
public class GenericSyncPipeline<TContext>(ILogger log, GenericPipelinePhases<TContext> phases) : ISyncPipeline
where TContext : IPipelineContext, new()
{
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
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);
if (phases.LogPhase.LogConfigPhaseAndExitIfNeeded(context))

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

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

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

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

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

@ -1,10 +1,19 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.Common;
using Recyclarr.ServarrApi.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 MediaNamingDto ApiFetchOutput { get; set; } = default!;
public MediaNamingDto TransactionOutput { get; set; } = default!;

@ -1,15 +1,16 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
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);
cache.Clear();
cache.AddTags(tags);
return tags;
context.ApiFetchOutput = tags;
}
}

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

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

@ -1,19 +1,16 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagTransactionPhase
public class TagTransactionPhase : ITransactionPipelinePhase<TagPipelineContext>
{
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification =
"This non-static method establishes a pattern that will eventually become an interface")]
public IList<string> Execute(IList<string> configTags, IList<SonarrTag> serviceTags)
public void Execute(TagPipelineContext context)
{
// 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.
return configTags
.Where(ct => serviceTags.All(st => !st.Label.EqualsIgnoreCase(ct)))
context.TransactionOutput = context.ConfigOutput
.Where(ct => context.ApiFetchOutput.All(st => !st.Label.EqualsIgnoreCase(ct)))
.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.Extras.AggregateService;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags;
@ -15,13 +13,13 @@ public class TagsAutofacModule : Module
.AsSelf()
.InstancePerLifetimeScope();
builder.RegisterType<SonarrTagApiService>().As<ISonarrTagApiService>();
builder.RegisterAggregateService<ITagPipelinePhases>();
builder.RegisterType<TagConfigPhase>();
builder.RegisterType<TagPreviewPhase>();
builder.RegisterType<TagApiFetchPhase>();
builder.RegisterType<TagTransactionPhase>();
builder.RegisterType<TagApiPersistencePhase>();
builder.RegisterTypes(
typeof(TagConfigPhase),
typeof(TagPreviewPhase),
typeof(TagApiFetchPhase),
typeof(TagTransactionPhase),
typeof(TagApiPersistencePhase),
typeof(TagLogPhase))
.AsImplementedInterfaces();
}
}

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

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

@ -21,12 +21,15 @@ public class TagApiPersistencePhaseTest
});
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, "four").Returns(new SonarrTag {Id = 4});
await sut.Execute(config, tagsToCreate);
await sut.Execute(context, config);
cache.Tags.Should().BeEquivalentTo(new[]
{

@ -1,3 +1,4 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary;
@ -8,19 +9,20 @@ namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
public class TagConfigPhaseTest
{
[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
{
ReleaseProfiles = Array.Empty<ReleaseProfileConfig>()
};
var result = sut.Execute(config);
result.Should().BeNull();
await sut.Execute(context, config);
context.ConfigOutput.Should().BeEmpty();
}
[Test, AutoMockData]
public void Return_tags(TagConfigPhase sut)
public void Output_not_empty_when_config_has_tags(TagConfigPhase sut)
{
var config = NewConfig.Sonarr() with
{
@ -33,7 +35,8 @@ public class TagConfigPhaseTest
}
};
var result = sut.Execute(config);
result.Should().BeEquivalentTo(config.ReleaseProfiles[0].Tags);
var context = new TagPipelineContext();
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.ServarrApi.Tag;
@ -9,41 +10,50 @@ public class TagTransactionPhaseTest
[Test, AutoMockData]
public void Return_tags_in_config_that_do_not_exist_in_service(TagTransactionPhase sut)
{
var configTags = new[] {"one", "two", "three"};
var serviceTags = new[]
var context = new TagPipelineContext
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
ConfigOutput = new[] {"one", "two", "three"},
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]
public void Return_all_tags_if_none_exist(TagTransactionPhase sut)
{
var configTags = new[] {"one", "two", "three"};
var serviceTags = Array.Empty<SonarrTag>();
var context = new TagPipelineContext
{
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]
public void No_tags_returned_if_all_exist(TagTransactionPhase sut)
{
var configTags = Array.Empty<string>();
var serviceTags = new[]
var context = new TagPipelineContext
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
ConfigOutput = Array.Empty<string>(),
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