feat!: Remove Sonarr v3 Support

Includes complete removal of Release Profile support.
pull/231/head
Robert Dailey 1 month ago
parent 147b3fc859
commit d45563cf1c

@ -18,7 +18,11 @@ changes you may need to make.
- **BREAKING**: The app data directory on OSX has changed. It now lives at `~/Library/Application
Support/recyclarr` instead of `~/.config/recyclarr`. Users will need to run `recyclarr migrate` to
move the directory (or do it manually).
- **BREAKING**: Removed support for Release Profiles and Sonarr version 3. The new minimum required
version for Sonarr is v4.0.0.
- CLI: Slightly improved display of version number when using `-v` option.
- CLI: Greatly improved the layout of and information in the local starter YAML configuration that
Recyclarr generates with the `recyclarr config create` command.
### Fixed

@ -11,12 +11,11 @@ guides](https://trash-guides.info/) to your Sonarr/Radarr instances.
## Features
Recyclarr supports Radarr, Sonarr v3, and Sonarr v4. The following information can be synced to
these services from the TRaSH Guides. For a more detailed features list, see the [Features] page.
Recyclarr supports Radarr and Sonarr (v4 and higher only). The following information can be synced
to these services from the TRaSH Guides. For a more detailed features list, see the [Features] page.
[Features]: https://recyclarr.dev/wiki/features/
- Release Profiles, including tags
- Quality Profiles, including qualities and quality groups
- Custom Formats, including scores (from guide or manual)
- Quality Definitions (file sizes)

@ -111,9 +111,6 @@
"include": {
"$ref": "config/includes.json"
},
"release_profiles": {
"$ref": "config/release-profiles.json"
},
"media_naming": {
"$ref": "config/media-naming-sonarr.json"
}

@ -1,50 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/config/release-profiles.json",
"type": "array",
"minItems": 1,
"items": {
"additionalProperties": false,
"required": ["trash_ids"],
"properties": {
"trash_ids": {
"$ref": "trash-ids.json"
},
"strict_negative_scores": {
"type": "boolean",
"default": false,
"description": "Enables preferred term scores less than 0 to be instead treated as \"Must Not Contain\" (ignored) terms."
},
"tags": {
"type": "array",
"description": "A list of one or more strings representing tags that will be applied to this release profile.",
"items": {
"type": "string"
}
},
"filter": {
"type": "object",
"additionalProperties": false,
"description": "Defines various ways that release profile terms from the guide are synchronized with Sonarr.",
"oneOf": [
{
"required": ["include"]
},
{
"required": ["exclude"]
}
],
"properties": {
"include": {
"$ref": "trash-ids.json",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be included in the created Release Profile in Sonarr."
},
"exclude": {
"$ref": "trash-ids.json",
"description": "A list of trash_id values representing terms (Required, Ignored, or Preferred) that should be excluded from the created Release Profile in Sonarr."
}
}
}
}
}
}

@ -13,8 +13,6 @@ using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.MediaNaming;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualitySize;
using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Processors;
using Recyclarr.Common;
using Recyclarr.Compatibility;
@ -69,22 +67,18 @@ public static class CompositionRoot
private static void PipelineRegistrations(ContainerBuilder builder)
{
builder.RegisterModule<TagsAutofacModule>();
builder.RegisterModule<CustomFormatAutofacModule>();
builder.RegisterModule<QualityProfileAutofacModule>();
builder.RegisterModule<QualitySizeAutofacModule>();
builder.RegisterModule<ReleaseProfileAutofacModule>();
builder.RegisterModule<MediaNamingAutofacModule>();
builder.RegisterGeneric(typeof(GenericPipelinePhases<>));
builder.RegisterTypes(
// ORDER HERE IS IMPORTANT!
// There are indirect dependencies between pipelines.
typeof(GenericSyncPipeline<TagPipelineContext>),
typeof(GenericSyncPipeline<CustomFormatPipelineContext>),
typeof(GenericSyncPipeline<QualityProfilePipelineContext>),
typeof(GenericSyncPipeline<QualitySizePipelineContext>),
typeof(GenericSyncPipeline<ReleaseProfilePipelineContext>),
typeof(GenericSyncPipeline<MediaNamingPipelineContext>))
.As<ISyncPipeline>()
.OrderByRegistration();

@ -18,7 +18,6 @@ public static class CliSetup
{
list.SetDescription("List information from the guide");
list.AddCommand<ListCustomFormatsCommand>("custom-formats");
list.AddCommand<ListReleaseProfilesCommand>("release-profiles");
list.AddCommand<ListQualitiesCommand>("qualities");
list.AddCommand<ListMediaNamingCommand>("naming");
});

@ -1,53 +0,0 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.Repo;
using Spectre.Console.Cli;
#pragma warning disable CS8765
namespace Recyclarr.Cli.Console.Commands;
[UsedImplicitly]
[Description("List Sonarr release profiles in the guide for a particular service.")]
public class ListReleaseProfilesCommand(ILogger log, ReleaseProfileDataLister lister, IMultiRepoUpdater repoUpdater)
: AsyncCommand<ListReleaseProfilesCommand.CliSettings>
{
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
{
[CommandOption("--terms")]
[Description(
"For the given Release Profile Trash ID, list terms in it that can be filtered in YAML format. " +
"Note that not every release profile has terms that may be filtered.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string? ListTerms { get; init; }
}
public override async Task<int> ExecuteAsync(CommandContext context, CliSettings settings)
{
await repoUpdater.UpdateAllRepositories(settings.CancellationToken);
try
{
if (settings.ListTerms is not null)
{
// Ignore nullability of ListTerms since the Settings.Validate() method will check for null/empty.
lister.ListTerms(settings.ListTerms!);
}
else
{
lister.ListReleaseProfiles();
}
}
catch (ArgumentException e)
{
log.Error(e, "Error");
return 1;
}
return 0;
}
}

@ -9,15 +9,6 @@ public class NamingFormatLookup
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string errorDescription)
{
return ObtainFormat(guideFormats, configFormatKey, null, errorDescription);
}
public string? ObtainFormat(
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string? keySuffix,
string errorDescription)
{
if (configFormatKey is null)
{
@ -29,11 +20,6 @@ public class NamingFormatLookup
var lowerKey = configFormatKey.ToLowerInvariant();
var keys = new List<string> {lowerKey};
if (keySuffix is not null)
{
// Put the more specific key first
keys.Insert(0, lowerKey + keySuffix);
}
foreach (var k in keys)
{

@ -1,43 +1,36 @@
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.TrashGuide.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases.Config;
public class SonarrMediaNamingConfigPhase(ISonarrCapabilityFetcher sonarrCapabilities)
: ServiceBasedMediaNamingConfigPhase<SonarrConfiguration>
public class SonarrMediaNamingConfigPhase : ServiceBasedMediaNamingConfigPhase<SonarrConfiguration>
{
protected override async Task<MediaNamingDto> ProcessNaming(
protected override Task<MediaNamingDto> ProcessNaming(
SonarrConfiguration config,
IMediaNamingGuideService guide,
NamingFormatLookup lookup)
{
var guideData = guide.GetSonarrNamingData();
var configData = config.MediaNaming;
var capabilities = await sonarrCapabilities.GetCapabilities(config);
var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3";
return new SonarrMediaNamingDto
return Task.FromResult<MediaNamingDto>(new SonarrMediaNamingDto
{
SeasonFolderFormat = lookup.ObtainFormat(guideData.Season, configData.Season, "Season Folder Format"),
SeriesFolderFormat = lookup.ObtainFormat(guideData.Series, configData.Series, "Series Folder Format"),
StandardEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Standard,
configData.Episodes?.Standard,
keySuffix,
"Standard Episode Format"),
DailyEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Daily,
configData.Episodes?.Daily,
keySuffix,
"Daily Episode Format"),
AnimeEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Anime,
configData.Episodes?.Anime,
keySuffix,
"Anime Episode Format"),
RenameEpisodes = configData.Episodes?.Rename
};
});
}
}

@ -1,9 +0,0 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public interface IReleaseProfileFilter
{
ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config);
}

@ -1,9 +0,0 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public interface IReleaseProfileFilterPipeline
{
ReleaseProfileData Process(ReleaseProfileData profile, ReleaseProfileConfig config);
}

@ -1,21 +0,0 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class IncludeExcludeFilter(ILogger log) : IReleaseProfileFilter
{
private readonly ReleaseProfileDataFilterer _filterer = new(log);
public ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config)
{
if (config.Filter == null)
{
return profile;
}
log.Debug("This profile will be filtered");
var newProfile = _filterer.FilterProfile(profile, config.Filter);
return newProfile ?? profile;
}
}

@ -1,84 +0,0 @@
using System.Collections.ObjectModel;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class ReleaseProfileDataFilterer(ILogger log)
{
private readonly ReleaseProfileDataValidationFilterer _validator = new(log);
public ReadOnlyCollection<TermData> ExcludeTerms(
IEnumerable<TermData> terms,
IEnumerable<string> excludeFilter)
{
var result = terms.Where(x => !excludeFilter.Contains(x.TrashId, StringComparer.InvariantCultureIgnoreCase));
return _validator.FilterTerms(result).ToList().AsReadOnly();
}
public ReadOnlyCollection<PreferredTermData> ExcludeTerms(
IEnumerable<PreferredTermData> terms,
IReadOnlyCollection<string> excludeFilter)
{
var result = terms
.Select(x => new PreferredTermData
{
Score = x.Score,
Terms = ExcludeTerms(x.Terms, excludeFilter)
});
return _validator.FilterTerms(result).ToList().AsReadOnly();
}
public ReadOnlyCollection<TermData> IncludeTerms(
IEnumerable<TermData> terms,
IEnumerable<string> includeFilter)
{
var result = terms.Where(x => includeFilter.Contains(x.TrashId, StringComparer.InvariantCultureIgnoreCase));
return _validator.FilterTerms(result).ToList().AsReadOnly();
}
public ReadOnlyCollection<PreferredTermData> IncludeTerms(
IEnumerable<PreferredTermData> terms,
IReadOnlyCollection<string> includeFilter)
{
var result = terms
.Select(x => new PreferredTermData
{
Score = x.Score,
Terms = IncludeTerms(x.Terms, includeFilter)
});
return _validator.FilterTerms(result).ToList().AsReadOnly();
}
public ReleaseProfileData? FilterProfile(
ReleaseProfileData selectedProfile,
SonarrProfileFilterConfig profileFilter)
{
if (profileFilter.Include.Count != 0)
{
log.Debug("Using inclusion filter");
return selectedProfile with
{
Required = IncludeTerms(selectedProfile.Required, profileFilter.Include),
Ignored = IncludeTerms(selectedProfile.Ignored, profileFilter.Include),
Preferred = IncludeTerms(selectedProfile.Preferred, profileFilter.Include)
};
}
if (profileFilter.Exclude.Count != 0)
{
log.Debug("Using exclusion filter");
return selectedProfile with
{
Required = ExcludeTerms(selectedProfile.Required, profileFilter.Exclude),
Ignored = ExcludeTerms(selectedProfile.Ignored, profileFilter.Exclude),
Preferred = ExcludeTerms(selectedProfile.Preferred, profileFilter.Exclude)
};
}
log.Debug("Filter property present but is empty");
return null;
}
}

@ -1,13 +0,0 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class ReleaseProfileFilterPipeline(IOrderedEnumerable<IReleaseProfileFilter> filters)
: IReleaseProfileFilterPipeline
{
public ReleaseProfileData Process(ReleaseProfileData profile, ReleaseProfileConfig config)
{
return filters.Aggregate(profile, (current, filter) => filter.Transform(current, config));
}
}

@ -1,24 +0,0 @@
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
public class StrictNegativeScoresFilter(ILogger log) : IReleaseProfileFilter
{
public ReleaseProfileData Transform(ReleaseProfileData profile, ReleaseProfileConfig config)
{
if (!config.StrictNegativeScores)
{
return profile;
}
log.Debug("Negative scores will be strictly ignored");
var splitPreferred = profile.Preferred.ToLookup(x => x.Score < 0);
return profile with
{
Ignored = profile.Ignored.Concat(splitPreferred[true].SelectMany(x => x.Terms)).ToList(),
Preferred = splitPreferred[false].ToList()
};
}
}

@ -1,9 +0,0 @@
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.Models;
public record ReleaseProfileTransactionData(
IReadOnlyCollection<SonarrReleaseProfile> UpdatedProfiles,
IReadOnlyCollection<SonarrReleaseProfile> CreatedProfiles,
IReadOnlyCollection<SonarrReleaseProfile> DeletedProfiles
);

@ -1,14 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileApiFetchPhase(IReleaseProfileApiService rpService)
: IApiFetchPipelinePhase<ReleaseProfilePipelineContext>
{
public async Task Execute(ReleaseProfilePipelineContext context, IServiceConfiguration config)
{
context.ApiFetchOutput = await rpService.GetReleaseProfiles(config);
}
}

@ -1,29 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileApiPersistencePhase(IReleaseProfileApiService api)
: IApiPersistencePipelinePhase<ReleaseProfilePipelineContext>
{
public async Task Execute(ReleaseProfilePipelineContext context, IServiceConfiguration config)
{
var transactions = context.TransactionOutput;
foreach (var profile in transactions.UpdatedProfiles)
{
await api.UpdateReleaseProfile(config, profile);
}
foreach (var profile in transactions.CreatedProfiles)
{
await api.CreateReleaseProfile(config, profile);
}
foreach (var profile in transactions.DeletedProfiles)
{
await api.DeleteReleaseProfile(config, profile.Id);
}
}
}

@ -1,53 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public record ProcessedReleaseProfileData(
ReleaseProfileData Profile,
IReadOnlyCollection<string> Tags
);
public class ReleaseProfileConfigPhase(
ILogger log,
IReleaseProfileGuideService guide,
IReleaseProfileFilterPipeline filters)
: IConfigPipelinePhase<ReleaseProfilePipelineContext>
{
public Task Execute(ReleaseProfilePipelineContext context, IServiceConfiguration config)
{
var releaseProfiles = ((SonarrConfiguration) config).ReleaseProfiles;
if (!releaseProfiles.Any())
{
log.Debug("{Instance} has no release profiles", config.InstanceName);
return Task.CompletedTask;
}
var profilesFromGuide = guide.GetReleaseProfileData();
var filteredProfiles = new List<ProcessedReleaseProfileData>();
var configProfiles = releaseProfiles.SelectMany(x => x.TrashIds.Select(y => (TrashId: y, Config: x)));
foreach (var (trashId, configProfile) in configProfiles)
{
// For each release profile specified in our YAML config, find the matching profile in the guide.
var selectedProfile = profilesFromGuide.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(trashId));
if (selectedProfile is null)
{
log.Warning("A release profile with Trash ID {TrashId} does not exist", trashId);
continue;
}
log.Debug("Found Release Profile: {ProfileName} ({TrashId})", selectedProfile.Name,
selectedProfile.TrashId);
selectedProfile = filters.Process(selectedProfile, configProfile);
filteredProfiles.Add(new ProcessedReleaseProfileData(selectedProfile, configProfile.Tags));
}
context.ConfigOutput = filteredProfiles;
return Task.CompletedTask;
}
}

@ -1,52 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileLogPhase(ILogger log) : ILogPipelinePhase<ReleaseProfilePipelineContext>
{
public bool LogConfigPhaseAndExitIfNeeded(ReleaseProfilePipelineContext context)
{
if (context.ConfigOutput is {Count: > 0})
{
return false;
}
log.Debug("No Release Profiles to process");
return true;
}
public void LogTransactionNotices(ReleaseProfilePipelineContext context)
{
}
public void LogPersistenceResults(ReleaseProfilePipelineContext context)
{
var transactions = context.TransactionOutput;
var somethingChanged = false;
if (transactions.UpdatedProfiles.Count != 0)
{
log.Information("Update existing profiles: {ProfileNames}",
transactions.UpdatedProfiles.Select(x => x.Name));
somethingChanged = true;
}
if (transactions.CreatedProfiles.Count != 0)
{
log.Information("Create new profiles: {ProfileNames}", transactions.CreatedProfiles.Select(x => x.Name));
somethingChanged = true;
}
if (transactions.DeletedProfiles.Count != 0)
{
log.Information("Deleting old release profiles: {ProfileNames}",
transactions.DeletedProfiles.Select(x => x.Name));
somethingChanged = true;
}
if (!somethingChanged)
{
log.Information("All Release Profiles are up to date!");
}
}
}

@ -1,86 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.ServarrApi.ReleaseProfile;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfilePreviewPhase(IAnsiConsole console) : IPreviewPipelinePhase<ReleaseProfilePipelineContext>
{
public void Execute(ReleaseProfilePipelineContext context)
{
var profiles = context.TransactionOutput;
var tree = new Tree("Release Profiles [red](Preview)[/]");
PrintCategoryOfChanges("Created Profiles", tree, profiles.CreatedProfiles);
PrintCategoryOfChanges("Updated Profiles", tree, profiles.UpdatedProfiles);
console.WriteLine();
console.Write(tree);
}
private void PrintCategoryOfChanges(string nodeTitle, Tree tree, IEnumerable<SonarrReleaseProfile> profiles)
{
var treeNode = tree.AddNode($"[green]{nodeTitle}[/]");
foreach (var profile in profiles)
{
PrintTermsAndScores(treeNode, profile);
}
}
private void PrintTermsAndScores(TreeNode tree, SonarrReleaseProfile profile)
{
var rpNode = tree.AddNode($"[yellow]{Markup.Escape(profile.Name)}[/]");
var incPreferred = profile.IncludePreferredWhenRenaming ? "[green]YES[/]" : "[red]NO[/]";
rpNode.AddNode($"Include Preferred when Renaming? {incPreferred}");
PrintTerms(rpNode, "Must Contain", profile.Required);
PrintTerms(rpNode, "Must Not Contain", profile.Ignored);
PrintPreferredTerms(rpNode, "Preferred", profile.Preferred);
console.WriteLine("");
}
private static void PrintTerms(TreeNode tree, string title, IReadOnlyCollection<string> terms)
{
if (terms.Count == 0)
{
return;
}
var table = new Table()
.AddColumn("[bold]Term[/]");
foreach (var term in terms)
{
table.AddRow(Markup.Escape(term));
}
tree.AddNode(title)
.AddNode(table);
}
private static void PrintPreferredTerms(
TreeNode tree,
string title,
IReadOnlyCollection<SonarrPreferredTerm> preferredTerms)
{
if (preferredTerms.Count <= 0)
{
return;
}
var table = new Table()
.AddColumn("[bold]Score[/]")
.AddColumn("[bold]Term[/]");
foreach (var term in preferredTerms)
{
table.AddRow(term.Score.ToString(), Markup.Escape(term.Term));
}
tree.AddNode(title)
.AddNode(table);
}
}

@ -1,67 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.ReleaseProfile.Models;
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
public class ReleaseProfileTransactionPhase(ServiceTagCache tagCache)
: ITransactionPipelinePhase<ReleaseProfilePipelineContext>
{
public void Execute(ReleaseProfilePipelineContext context, IServiceConfiguration config)
{
var created = new List<SonarrReleaseProfile>();
var updated = new List<SonarrReleaseProfile>();
foreach (var configProfile in context.ConfigOutput)
{
var title = $"[Trash] {configProfile.Profile.Name}";
var matchingServiceProfile = context.ApiFetchOutput.FirstOrDefault(x => x.Name.EqualsIgnoreCase(title));
if (matchingServiceProfile is not null)
{
SetupProfileRequestObject(matchingServiceProfile, configProfile);
updated.Add(matchingServiceProfile);
}
else
{
var profileToUpdate = new SonarrReleaseProfile {Name = title, Enabled = true};
SetupProfileRequestObject(profileToUpdate, configProfile);
created.Add(profileToUpdate);
}
}
var deleted = DeleteOldManagedProfiles(context.ApiFetchOutput, context.ConfigOutput.AsReadOnly());
context.TransactionOutput = new ReleaseProfileTransactionData(updated, created, deleted);
}
private static List<SonarrReleaseProfile> DeleteOldManagedProfiles(
IList<SonarrReleaseProfile> serviceData,
IReadOnlyList<ProcessedReleaseProfileData> configProfiles)
{
var profiles = configProfiles.Select(x => x.Profile).ToList();
return serviceData
.Where(sonarrProfile =>
{
return sonarrProfile.Name.StartsWithIgnoreCase("[Trash]") &&
!profiles.Exists(profile => sonarrProfile.Name.EndsWithIgnoreCase(profile.Name));
})
.ToList();
}
private void SetupProfileRequestObject(SonarrReleaseProfile profileToUpdate, ProcessedReleaseProfileData profile)
{
profileToUpdate.Preferred = profile.Profile.Preferred
.SelectMany(x => x.Terms.Select(termData => new SonarrPreferredTerm(x.Score, termData.Term)))
.ToList();
profileToUpdate.Ignored = profile.Profile.Ignored.Select(x => x.Term).ToList();
profileToUpdate.Required = profile.Profile.Required.Select(x => x.Term).ToList();
profileToUpdate.IncludePreferredWhenRenaming = profile.Profile.IncludePreferredWhenRenaming;
profileToUpdate.Tags = profile.Tags
.Select(tagCache.GetTagIdByName)
.NotNull()
.ToList();
}
}

@ -1,35 +0,0 @@
using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
using Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile;
public class ReleaseProfileAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<ReleaseProfileApiService>().As<IReleaseProfileApiService>();
builder.RegisterType<ReleaseProfileFilterPipeline>().As<IReleaseProfileFilterPipeline>();
builder.RegisterType<ReleaseProfileDataLister>();
// Release Profile Filters (ORDER MATTERS!)
builder.RegisterTypes(
typeof(IncludeExcludeFilter),
typeof(StrictNegativeScoresFilter))
.As<IReleaseProfileFilter>()
.OrderByRegistration();
builder.RegisterTypes(
typeof(ReleaseProfileConfigPhase),
typeof(ReleaseProfilePreviewPhase),
typeof(ReleaseProfileApiFetchPhase),
typeof(ReleaseProfileTransactionPhase),
typeof(ReleaseProfileApiPersistencePhase),
typeof(ReleaseProfileLogPhase))
.AsImplementedInterfaces();
}
}

@ -1,87 +0,0 @@
using System.Text;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashGuide.ReleaseProfile;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile;
public class ReleaseProfileDataLister(IAnsiConsole console, IReleaseProfileGuideService guide)
{
public void ListReleaseProfiles()
{
console.WriteLine("\nList of Release Profiles in the TRaSH Guides:\n");
var profilesFromGuide = guide.GetReleaseProfileData();
foreach (var profile in profilesFromGuide)
{
console.WriteLine($" - {profile.TrashId} # {profile.Name}");
}
console.WriteLine(
"\nThe above Release Profiles are in YAML format and ready to be copied & pasted under the `trash_ids:` property.");
}
private static bool HasIdentifiableTerms(ReleaseProfileData profile)
{
static bool HasTrashIds(IEnumerable<TermData> terms)
{
return terms.Any(x => !string.IsNullOrEmpty(x.TrashId));
}
return
HasTrashIds(profile.Ignored) ||
HasTrashIds(profile.Required) ||
HasTrashIds(profile.Preferred.SelectMany(x => x.Terms));
}
public void ListTerms(string releaseProfileId)
{
var profile = guide.GetReleaseProfileData()
.FirstOrDefault(x => x.TrashId.EqualsIgnoreCase(releaseProfileId));
if (profile is null)
{
throw new ArgumentException("No release profile found with that Trash ID");
}
if (!HasIdentifiableTerms(profile))
{
throw new ArgumentException(
"This release profile has no terms that can be filtered " +
"(terms must have Trash IDs assigned in order to be filtered)");
}
console.WriteLine();
console.WriteLine($"List of Terms for the '{profile.Name}' Release Profile that may be filtered:\n");
PrintTerms(profile.Required, "Required");
PrintTerms(profile.Ignored, "Ignored");
PrintTerms(profile.Preferred.SelectMany(x => x.Terms), "Preferred");
console.WriteLine(
"The above Term Filters are in YAML format and ready to be copied & pasted under the `include:` or `exclude:` filter properties.");
}
private void PrintTerms(IEnumerable<TermData> terms, string category)
{
var filteredTerms = terms.Where(x => x.TrashId.Length != 0).ToList();
if (filteredTerms.Count == 0)
{
return;
}
console.WriteLine($"{category} Terms:\n");
foreach (var term in filteredTerms)
{
var line = new StringBuilder($" - {term.TrashId}");
if (term.Name.Length != 0)
{
line.Append($" # {term.Name}");
}
console.WriteLine(line.ToString());
}
console.WriteLine();
}
}

@ -1,23 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Cli.Pipelines.ReleaseProfile.Models;
using Recyclarr.Cli.Pipelines.ReleaseProfile.PipelinePhases;
using Recyclarr.Common;
using Recyclarr.ServarrApi.ReleaseProfile;
namespace Recyclarr.Cli.Pipelines.ReleaseProfile;
[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 ReleaseProfilePipelineContext : IPipelineContext
{
public string PipelineDescription => "Release Profile Pipeline";
public IReadOnlyCollection<SupportedServices> SupportedServiceTypes { get; } = new[]
{
SupportedServices.Sonarr
};
public IList<ProcessedReleaseProfileData> ConfigOutput { get; set; } = default!;
public IList<SonarrReleaseProfile> ApiFetchOutput { get; set; } = default!;
public ReleaseProfileTransactionData TransactionOutput { get; set; } = default!;
}

@ -1,16 +0,0 @@
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 Execute(TagPipelineContext context, IServiceConfiguration config)
{
var tags = await api.GetTags(config);
cache.AddTags(tags);
context.ApiFetchOutput = tags;
}
}

@ -1,21 +0,0 @@
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)
: IApiPersistencePipelinePhase<TagPipelineContext>
{
public async Task Execute(TagPipelineContext context, IServiceConfiguration config)
{
var createdTags = new List<SonarrTag>();
foreach (var tag in context.TransactionOutput)
{
log.Debug("Creating Tag: {Tag}", tag);
createdTags.Add(await api.CreateTag(config, tag));
}
cache.AddTags(createdTags);
}
}

@ -1,17 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagConfigPhase : IConfigPipelinePhase<TagPipelineContext>
{
public Task Execute(TagPipelineContext context, IServiceConfiguration config)
{
context.ConfigOutput = ((SonarrConfiguration) config).ReleaseProfiles
.SelectMany(x => x.Tags)
.Distinct()
.ToList();
return Task.CompletedTask;
}
}

@ -1,35 +0,0 @@
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 LogTransactionNotices(TagPipelineContext context)
{
}
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,31 +0,0 @@
using Castle.Core.Internal;
using Recyclarr.Cli.Pipelines.Generic;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagPreviewPhase(IAnsiConsole console) : IPreviewPipelinePhase<TagPipelineContext>
{
public void Execute(TagPipelineContext context)
{
var tagsToCreate = context.TransactionOutput;
if (tagsToCreate.IsNullOrEmpty())
{
console.WriteLine();
console.MarkupLine("[green]No tags to create[/]");
console.WriteLine();
return;
}
var table = new Table {Border = TableBorder.Simple};
table.AddColumn("[olive]Tags To Create[/]");
foreach (var tag in tagsToCreate)
{
table.AddRow(tag);
}
console.Write(table);
}
}

@ -1,17 +0,0 @@
using Recyclarr.Cli.Pipelines.Generic;
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
public class TagTransactionPhase : ITransactionPipelinePhase<TagPipelineContext>
{
public void Execute(TagPipelineContext context, IServiceConfiguration config)
{
// 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.
context.TransactionOutput = context.ConfigOutput
.Where(ct => context.ApiFetchOutput.All(st => !st.Label.EqualsIgnoreCase(ct)))
.ToList();
}
}

@ -1,27 +0,0 @@
using Recyclarr.Common.Extensions;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Pipelines.Tags;
public class ServiceTagCache : IPipelineCache
{
private readonly HashSet<SonarrTag> _serviceTags = new();
public IEnumerable<SonarrTag> Tags => _serviceTags;
public void AddTags(IEnumerable<SonarrTag> tags)
{
_serviceTags.AddRange(tags);
}
public int? GetTagIdByName(string name)
{
var foundTag = _serviceTags.FirstOrDefault(x => x.Label.EqualsIgnoreCase(name));
return foundTag?.Id;
}
public void Clear()
{
_serviceTags.Clear();
}
}

@ -1,21 +0,0 @@
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,25 +0,0 @@
using Autofac;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.Tags;
public class TagsAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<ServiceTagCache>().As<IPipelineCache>()
.AsSelf()
.InstancePerLifetimeScope();
builder.RegisterTypes(
typeof(TagConfigPhase),
typeof(TagPreviewPhase),
typeof(TagApiFetchPhase),
typeof(TagTransactionPhase),
typeof(TagApiPersistencePhase),
typeof(TagLogPhase))
.AsImplementedInterfaces();
}
}

@ -1,8 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Console;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Compatibility;
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.CustomFormat;
@ -15,16 +13,13 @@ public class DeleteCustomFormatsProcessor(
ILogger log,
IAnsiConsole console,
ICustomFormatApiService api,
IConfigurationRegistry configRegistry,
ISonarrCapabilityFetcher sonarCapabilities)
IConfigurationRegistry configRegistry)
: IDeleteCustomFormatsProcessor
{
public async Task Process(IDeleteCustomFormatSettings settings)
{
var config = GetTargetConfig(settings);
await CheckCustomFormatSupport(config);
var cfs = await ObtainCustomFormats(config);
if (!settings.All)
@ -61,18 +56,6 @@ public class DeleteCustomFormatsProcessor(
await DeleteCustomFormats(cfs, config);
}
private async Task CheckCustomFormatSupport(IServiceConfiguration config)
{
if (config is SonarrConfiguration)
{
var capabilities = await sonarCapabilities.GetCapabilities(config);
if (!capabilities.SupportsCustomFormats)
{
throw new ServiceIncompatibilityException("Custom formats are not supported in Sonarr v3");
}
}
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private async Task DeleteCustomFormats(ICollection<CustomFormatData> cfs, IServiceConfiguration config)
{

@ -1,75 +1,41 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/config-schema.json
# A starter config to use with Recyclarr. Most values are set to "reasonable defaults". Update the
# values below as needed for your instance. You will be required to update the API Key and URL for
# each instance you want to use.
# An empty starter config to use with Recyclarr. Update the values below as needed for your
# instance. You will be required to update the `api_key` and `base_url` for each instance you want
# to use.
#
# Many optional settings have been omitted to keep this template simple. Note that there's no "one
# size fits all" configuration. Please refer to the guide to understand how to build the appropriate
# configuration based on your hardware setup and capabilities.
# If you'd rather use pre-built configuration instead of building your own from scratch, see these
# pages:
# - Config Templates: https://recyclarr.dev/wiki/guide-configs/
# - CLI Command: http://recyclarr.dev/wiki/cli/config/list/templates/
#
# For any lines that mention uncommenting YAML, you simply need to remove the leading hash (`#`).
# The YAML comments will already be at the appropriate indentation.
# This file WILL NOT WORK as it currently is. You need to read the documentation and build this
# configuration from scratch. Note that there's no "one size fits all" configuration. Please refer
# to the TRaSH Guides to understand how to build the appropriate configuration based on your
# hardware setup and capabilities.
#
# For more details on the configuration, see the Configuration Reference on the wiki here:
# https://recyclarr.dev/wiki/yaml/config-reference/
#
# Want a more flexible file layout?
# See: https://recyclarr.dev/wiki/file-structure/
# Configuration specific to Sonarr
# Configuration specific to Sonarr. For Radarr, the layout is the same.
# See: http://recyclarr.dev/wiki/yaml/config-reference/basic/
sonarr:
series:
# Set the URL/API Key to your actual instance
base_url: http://localhost:8989
api_key: YOUR_KEY_HERE
# Quality definitions from the guide to sync to Sonarr. Choices: series, anime
quality_definition:
type: series
# Release profiles from the guide to sync to Sonarr v3 (Sonarr v4 does not use this!)
# Use `recyclarr list release-profiles` for values you can put here.
# https://trash-guides.info/Sonarr/Sonarr-Release-Profile-RegEx/
release_profiles:
# Series
- trash_ids:
- EBC725268D687D588A20CBC5F97E538B # Low Quality Groups
- 1B018E0C53EC825085DD911102E2CA36 # Release Sources (Streaming Service)
- 71899E6C303A07AF0E4746EFF9873532 # P2P Groups + Repack/Proper
# Anime (Uncomment below if you want it)
#- trash_ids:
# - d428eda85af1df8904b4bbe4fc2f537c # Anime - First release profile
# - 6cd9e10bb5bb4c63d2d7cd3279924c7b # Anime - Second release profile
# Configuration specific to Radarr.
radarr:
movies:
# Set the URL/API Key to your actual instance
base_url: http://localhost:7878
api_key: YOUR_KEY_HERE
# Which quality definition in the guide to sync to Radarr. Only choice right now is 'movie'
# See: https://recyclarr.dev/wiki/yaml/config-reference/quality-definition/
quality_definition:
type: movie
# Set to 'true' to automatically remove custom formats from Radarr when they are removed from
# the guide or your configuration. This will NEVER delete custom formats you manually created!
delete_old_custom_formats: false
# See: http://localhost:3000/wiki/yaml/config-reference/quality-profiles/
quality_profiles:
# See: http://localhost:3000/wiki/yaml/config-reference/custom-formats/
custom_formats:
# A list of custom formats to sync to Radarr.
# Use `recyclarr list custom-formats radarr` for values you can put here.
# https://trash-guides.info/Radarr/Radarr-collection-of-custom-formats/
- trash_ids:
- ed38b889b31be83fda192888e2286d83 # BR-DISK
- 90cedc1fea7ea5d11298bebd3d1d3223 # EVO (no WEBDL)
- 90a6f9a284dff5103f6346090e6280c8 # LQ
- dc98083864ea246d05a42df0d05f81cc # x265 (720/1080p)
- b8cd450cbfa689c0259a01d9e29ba3d6 # 3D
# Uncomment the below properties to specify one or more quality profiles that should be
# updated with scores from the guide for each custom format. Without this, custom formats
# are synced to Radarr but no scores are set in any quality profiles.
#quality_profiles:
# - name: Quality Profile 1
# - name: Quality Profile 2
# #score: -9999 # Optional score to assign to all CFs. Overrides scores in the guide.
# #reset_unmatched_scores: true # Optionally set other scores to 0 if they are not listed in 'names' above.
# See: http://localhost:3000/wiki/yaml/config-reference/media-naming/
media_naming:

@ -2,9 +2,16 @@ namespace Recyclarr.Compatibility.Sonarr;
public record SonarrCapabilities
{
public static Version MinimumVersion { get; } = new("3.0.9.1549");
public SonarrCapabilities()
{
}
public Version Version { get; init; } = new();
public SonarrCapabilities(Version version)
{
Version = version;
}
public static Version MinimumVersion { get; } = new("4.0.0.0");
public bool SupportsCustomFormats { get; init; }
public Version Version { get; init; } = new();
}

@ -1,4 +1,3 @@
using Recyclarr.Common.Extensions;
using Recyclarr.Config.Models;
namespace Recyclarr.Compatibility.Sonarr;
@ -15,36 +14,5 @@ public class SonarrCapabilityEnforcer(ISonarrCapabilityFetcher capabilityFetcher
$"Your Sonarr version {capabilities.Version} does not meet the minimum " +
$"required version of {SonarrCapabilities.MinimumVersion}.");
}
switch (capabilities.SupportsCustomFormats)
{
case true when config.ReleaseProfiles.IsNotEmpty():
throw new ServiceIncompatibilityException(
"Release profiles require Sonarr v3. " +
"Please use `custom_formats` instead or use the right version of Sonarr.");
case false when config.CustomFormats.IsNotEmpty():
throw new ServiceIncompatibilityException(
"Custom formats require Sonarr v4 or greater. " +
"Please use `release_profiles` instead or use the right version of Sonarr.");
}
// Check for aspects of quality profile sync that are not supported by Sonarr v3
if (!capabilities.SupportsCustomFormats)
{
if (config.QualityProfiles.Any(x => x.UpgradeUntilScore is not null))
{
throw new ServiceIncompatibilityException(
"`until_score` under `upgrade` is not supported by Sonarr v3. " +
"Remove the until_score property or use Sonarr v4.");
}
if (config.QualityProfiles.Any(x => x.MinFormatScore is not null))
{
throw new ServiceIncompatibilityException(
"`min_format_score` under `quality_profiles` is not supported by Sonarr v3. " +
"Remove the min_format_score property or use Sonarr v4.");
}
}
}
}

@ -7,10 +7,7 @@ public class SonarrCapabilityFetcher(IServiceInformation info)
{
return new SonarrCapabilities
{
Version = version,
SupportsCustomFormats =
version >= new Version(4, 0)
Version = version
};
}
}

@ -25,7 +25,7 @@ public static class ConfigExtensions
this IEnumerable<IServiceConfiguration> configs,
ConfigFilterCriteria criteria)
{
// later, if we filter by "operation type" (e.g. release profiles, CFs, quality sizes) it's just another
// later, if we filter by "operation type" (e.g. CFs, quality sizes) it's just another
// ".Where()" in the LINQ expression below.
return configs.GetConfigsOfType(criteria.Service)
.Where(x => criteria.Instances.IsEmpty() ||

@ -6,26 +6,9 @@ public record SonarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Sonarr;
public IList<ReleaseProfileConfig> ReleaseProfiles { get; init; } =
Array.Empty<ReleaseProfileConfig>();
public SonarrMediaNamingConfig MediaNaming { get; init; } = new();
}
public class ReleaseProfileConfig
{
public IReadOnlyCollection<string> TrashIds { get; init; } = Array.Empty<string>();
public bool StrictNegativeScores { get; init; }
public IReadOnlyCollection<string> Tags { get; init; } = Array.Empty<string>();
public SonarrProfileFilterConfig? Filter { get; init; }
}
public class SonarrProfileFilterConfig
{
public IReadOnlyCollection<string> Include { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Exclude { get; init; } = Array.Empty<string>();
}
public record SonarrMediaNamingConfig
{
public string? Season { get; init; }

@ -2,22 +2,6 @@ using JetBrains.Annotations;
namespace Recyclarr.Config.Parsing;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileFilterConfigYaml
{
public IReadOnlyCollection<string>? Include { get; init; }
public IReadOnlyCollection<string>? Exclude { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; init; }
public bool StrictNegativeScores { get; init; }
public IReadOnlyCollection<string>? Tags { get; init; }
public ReleaseProfileFilterConfigYaml? Filter { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrEpisodeNamingConfigYaml
{
@ -38,6 +22,5 @@ public record SonarrMediaNamingConfigYaml
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrConfigYaml : ServiceConfigYaml
{
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; init; }
public SonarrMediaNamingConfigYaml? MediaNaming { get; init; }
}

@ -206,45 +206,6 @@ public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
public SonarrConfigYamlValidator()
{
Include(new ServiceConfigYamlValidator());
RuleFor(x => x)
.Must(x => OnlyOneHasElements(x.ReleaseProfiles, x.CustomFormats))
.WithMessage("`custom_formats` and `release_profiles` may not be used together");
RuleForEach(x => x.ReleaseProfiles).SetValidator(new ReleaseProfileConfigYamlValidator());
}
}
public class ReleaseProfileConfigYamlValidator : CustomValidator<ReleaseProfileConfigYaml>
{
public ReleaseProfileConfigYamlValidator()
{
RuleFor(x => x.TrashIds).NotEmpty()
.WithMessage("'trash_ids' is required for 'release_profiles' elements");
RuleFor(x => x.Filter)
.SetNonNullableValidator(new ReleaseProfileFilterConfigYamlValidator());
}
}
public class ReleaseProfileFilterConfigYamlValidator : CustomValidator<ReleaseProfileFilterConfigYaml>
{
public ReleaseProfileFilterConfigYamlValidator()
{
// Include & Exclude may not be used together
RuleFor(x => x)
.Must(x => OnlyOneHasElements(x.Include, x.Exclude))
.WithMessage("'include' and 'exclude' may not be used together")
.DependentRules(() =>
{
RuleFor(x => x.Include).NotEmpty()
.When(x => x.Include is not null)
.WithMessage("'include' under 'filter' must have at least 1 Trash ID");
RuleFor(x => x.Exclude).NotEmpty()
.When(x => x.Exclude is not null)
.WithMessage("'exclude' under 'filter' must have at least 1 Trash ID");
});
}
}

@ -12,8 +12,6 @@ public class ConfigYamlMapperProfile : Profile
CreateMap<QualityScoreConfigYaml, QualityProfileScoreConfig>();
CreateMap<CustomFormatConfigYaml, CustomFormatConfig>();
CreateMap<QualitySizeConfigYaml, QualityDefinitionConfig>();
CreateMap<ReleaseProfileConfigYaml, ReleaseProfileConfig>();
CreateMap<ReleaseProfileFilterConfigYaml, SonarrProfileFilterConfig>();
CreateMap<ResetUnmatchedScoresConfigYaml, ResetUnmatchedScoresConfig>();
CreateMap<RadarrMediaNamingConfigYaml, RadarrMediaNamingConfig>();

@ -34,6 +34,14 @@ public static class ConfigContextualMessages
"See: https://recyclarr.dev/wiki/upgrade-guide/v6.0/#reset-scores";
}
if (msg.Contains("Property 'release_profiles' not found on type"))
{
return
"Release profiles and Sonarr v3 in general are no longer supported. All instances of " +
"`release_profiles` in your configuration YAML must be removed. " +
"https://recyclarr.dev/wiki/upgrade-guide/v7.0/#sonarr-v3-removal";
}
return null;
}
}

@ -8,9 +8,6 @@ public class SonarrConfigMerger : ServiceConfigMerger<SonarrConfigYaml>
{
return base.Merge(a, b) with
{
ReleaseProfiles = Combine(a.ReleaseProfiles, b.ReleaseProfiles,
(x, y) => x.Concat(y).ToList()),
MediaNaming = Combine(a.MediaNaming, b.MediaNaming, MergeMediaNaming)
};
}

@ -9,7 +9,6 @@ public record RadarrMetadata
public record SonarrMetadata
{
public IReadOnlyCollection<string> ReleaseProfiles { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Qualities { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> CustomFormats { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Naming { get; init; } = Array.Empty<string>();

@ -7,7 +7,6 @@ using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.ServarrApi.QualityProfile;
using Recyclarr.ServarrApi.System;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.ServarrApi;
@ -26,7 +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>();
builder.RegisterTypes(
typeof(FlurlAfterCallHandler),

@ -1,11 +0,0 @@
using Recyclarr.Config.Models;
namespace Recyclarr.ServarrApi.ReleaseProfile;
public interface IReleaseProfileApiService
{
Task UpdateReleaseProfile(IServiceConfiguration config, SonarrReleaseProfile profile);
Task<SonarrReleaseProfile> CreateReleaseProfile(IServiceConfiguration config, SonarrReleaseProfile profile);
Task<IList<SonarrReleaseProfile>> GetReleaseProfiles(IServiceConfiguration config);
Task DeleteReleaseProfile(IServiceConfiguration config, int releaseProfileId);
}

@ -1,35 +0,0 @@
using Flurl.Http;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Http.Servarr;
namespace Recyclarr.ServarrApi.ReleaseProfile;
public class ReleaseProfileApiService(IServarrRequestBuilder service) : IReleaseProfileApiService
{
public async Task UpdateReleaseProfile(IServiceConfiguration config, SonarrReleaseProfile profile)
{
await service.Request(config, "releaseprofile", profile.Id)
.PutJsonAsync(profile);
}
public async Task<SonarrReleaseProfile> CreateReleaseProfile(
IServiceConfiguration config,
SonarrReleaseProfile profile)
{
return await service.Request(config, "releaseprofile")
.PostJsonAsync(profile)
.ReceiveJson<SonarrReleaseProfile>();
}
public async Task<IList<SonarrReleaseProfile>> GetReleaseProfiles(IServiceConfiguration config)
{
return await service.Request(config, "releaseprofile")
.GetJsonAsync<List<SonarrReleaseProfile>>();
}
public async Task DeleteReleaseProfile(IServiceConfiguration config, int releaseProfileId)
{
await service.Request(config, "releaseprofile", releaseProfileId)
.DeleteAsync();
}
}

@ -1,28 +0,0 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.ServarrApi.ReleaseProfile;
[UsedImplicitly(ImplicitUseKindFlags.Assign, ImplicitUseTargetFlags.Members)]
public class SonarrPreferredTerm(int score, string term)
{
[JsonPropertyName("key")]
public string Term { get; set; } = term;
[JsonPropertyName("value")]
public int Score { get; set; } = score;
}
[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,9 +0,0 @@
using Recyclarr.Config.Models;
namespace Recyclarr.ServarrApi.Tag;
public interface ISonarrTagApiService
{
Task<IList<SonarrTag>> GetTags(IServiceConfiguration config);
Task<SonarrTag> CreateTag(IServiceConfiguration config, string tag);
}

@ -1,13 +0,0 @@
using JetBrains.Annotations;
using Recyclarr.Common;
namespace Recyclarr.ServarrApi.Tag;
public class SonarrTag
{
public static IEqualityComparer<SonarrTag> Comparer { get; } =
new GenericEqualityComparer<SonarrTag>((x, y) => x.Id == y.Id, x => x.Id);
public string Label { get; [UsedImplicitly] set; } = "";
public int Id { get; [UsedImplicitly] set; }
}

@ -1,21 +0,0 @@
using Flurl.Http;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Http.Servarr;
namespace Recyclarr.ServarrApi.Tag;
public class SonarrTagApiService(IServarrRequestBuilder service) : ISonarrTagApiService
{
public async Task<IList<SonarrTag>> GetTags(IServiceConfiguration config)
{
return await service.Request(config, "tag")
.GetJsonAsync<List<SonarrTag>>();
}
public async Task<SonarrTag> CreateTag(IServiceConfiguration config, string tag)
{
return await service.Request(config, "tag")
.PostJsonAsync(new {label = tag})
.ReceiveJson<SonarrTag>();
}
}

@ -2,7 +2,6 @@ using Autofac;
using Recyclarr.TrashGuide.CustomFormat;
using Recyclarr.TrashGuide.MediaNaming;
using Recyclarr.TrashGuide.QualitySize;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.TrashGuide;
@ -19,10 +18,6 @@ public class GuideAutofacModule : Module
builder.RegisterType<CustomFormatLoader>().As<ICustomFormatLoader>();
builder.RegisterType<CustomFormatCategoryParser>().As<ICustomFormatCategoryParser>();
// Release Profile
builder.RegisterType<ReleaseProfileGuideParser>();
builder.RegisterType<ReleaseProfileGuideService>().As<IReleaseProfileGuideService>().SingleInstance();
// Quality Size
builder.RegisterType<QualitySizeGuideService>().As<IQualitySizeGuideService>().SingleInstance();
builder.RegisterType<QualitySizeGuideParser>();

@ -1,6 +0,0 @@
namespace Recyclarr.TrashGuide.ReleaseProfile;
public interface IReleaseProfileGuideService
{
IReadOnlyList<ReleaseProfileData> GetReleaseProfileData();
}

@ -1,60 +0,0 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.TrashGuide.ReleaseProfile;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record TermData
{
[JsonPropertyName("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Term { get; init; } = string.Empty;
public sealed override string ToString()
{
return $"[TrashId: {TrashId}] [Name: {Name}] [Term: {Term}]";
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record PreferredTermData
{
public int Score { get; init; }
public IReadOnlyCollection<TermData> Terms { get; init; } = Array.Empty<TermData>();
public void Deconstruct(out int score, out IReadOnlyCollection<TermData> terms)
{
score = Score;
terms = Terms;
}
public sealed override string ToString()
{
return $"[Score: {Score}] [Terms: {Terms.Count}]";
}
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileData
{
[JsonPropertyName("trash_id")]
public string TrashId { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public bool IncludePreferredWhenRenaming { get; init; }
public IReadOnlyCollection<TermData> Required { get; init; } = Array.Empty<TermData>();
public IReadOnlyCollection<TermData> Ignored { get; init; } = Array.Empty<TermData>();
public IReadOnlyCollection<PreferredTermData> Preferred { get; init; } = Array.Empty<PreferredTermData>();
public sealed override string ToString()
{
return $"[TrashId: {TrashId}] " +
$"[Name: {Name}] " +
$"[IncludePreferred: {IncludePreferredWhenRenaming}] " +
$"[Required: {Required.Count}] " +
$"[Ignored: {Ignored.Count}] " +
$"[Preferred: {Preferred.Count}]";
}
}

@ -1,43 +0,0 @@
using FluentValidation.Results;
using Recyclarr.Common.FluentValidation;
namespace Recyclarr.TrashGuide.ReleaseProfile;
public class ReleaseProfileDataValidationFilterer(ILogger log)
{
private void LogInvalidTerm(IReadOnlyCollection<ValidationFailure> failures, string filterDescription)
{
log.Debug("Validation failed on term data ({Filter}): {Failures}", filterDescription, failures);
}
public IEnumerable<TermData> FilterTerms(IEnumerable<TermData> terms)
{
return terms.IsValid(new TermDataValidator(), (e, x) => LogInvalidTerm(e, x.ToString()));
}
public IEnumerable<PreferredTermData> FilterTerms(IEnumerable<PreferredTermData> terms)
{
return terms.IsValid(new PreferredTermDataValidator(), (e, x) => LogInvalidTerm(e, x.ToString()));
}
private ReleaseProfileData FilterProfile(ReleaseProfileData profile)
{
return profile with
{
Required = FilterTerms(profile.Required).ToList(),
Ignored = FilterTerms(profile.Ignored).ToList(),
Preferred = FilterTerms(profile.Preferred).ToList()
};
}
public IEnumerable<ReleaseProfileData> FilterProfiles(IEnumerable<ReleaseProfileData> data)
{
return data
.Select(FilterProfile)
.IsValid(new ReleaseProfileDataValidator(), (e, x) =>
{
log.Warning("Excluding invalid release profile: {Profile}", x.ToString());
log.Debug("Release profile excluded for these reasons: {Reasons}", e);
});
}
}

@ -1,32 +0,0 @@
using FluentValidation;
namespace Recyclarr.TrashGuide.ReleaseProfile;
public class TermDataValidator : AbstractValidator<TermData>
{
public TermDataValidator()
{
RuleFor(x => x.Term).NotEmpty();
}
}
public class PreferredTermDataValidator : AbstractValidator<PreferredTermData>
{
public PreferredTermDataValidator()
{
RuleFor(x => x.Terms).NotEmpty();
RuleForEach(x => x.Terms).SetValidator(new TermDataValidator());
}
}
public class ReleaseProfileDataValidator : AbstractValidator<ReleaseProfileData>
{
public ReleaseProfileDataValidator()
{
RuleFor(x => x.Name).NotEmpty();
RuleFor(x => x.TrashId).NotEmpty();
RuleFor(x => x)
.Must(x => x.Required.Count != 0 || x.Ignored.Count != 0 || x.Preferred.Count != 0)
.WithMessage("Must have at least one of Required, Ignored, or Preferred terms");
}
}

@ -1,54 +0,0 @@
using System.IO.Abstractions;
using System.Text.Json;
using Recyclarr.Json;
namespace Recyclarr.TrashGuide.ReleaseProfile;
public class ReleaseProfileGuideParser(ILogger log)
{
private readonly JsonSerializerOptions _jsonSettings = new(GlobalJsonSerializerSettings.Services)
{
Converters =
{
new CollectionJsonConverter(),
new TermDataConverter()
}
};
private async Task<ReleaseProfileData?> LoadAndParseFile(IFileInfo file)
{
try
{
await using var stream = file.OpenRead();
return await JsonSerializer.DeserializeAsync<ReleaseProfileData>(stream, _jsonSettings);
}
catch (JsonException e)
{
HandleJsonException(e, file);
}
catch (AggregateException ae) when (ae.InnerException is JsonException e)
{
HandleJsonException(e, file);
}
return null;
}
private void HandleJsonException(JsonException exception, IFileInfo file)
{
log.Warning(exception,
"Failed to parse Sonarr JSON file (This likely indicates a bug that should be " +
"reported in the TRaSH repo): {File}", file.Name);
}
public IEnumerable<ReleaseProfileData> GetReleaseProfileData(IEnumerable<IDirectoryInfo> paths)
{
var tasks = JsonUtils.GetJsonFilesInDirectories(paths, log).Select(LoadAndParseFile);
var data = Task.WhenAll(tasks).Result
// Make non-nullable type and filter out null values
.Choose(x => x is not null ? (true, x) : default);
var validator = new ReleaseProfileDataValidationFilterer(log);
return validator.FilterProfiles(data);
}
}

@ -1,23 +0,0 @@
using Recyclarr.Repo;
namespace Recyclarr.TrashGuide.ReleaseProfile;
public class ReleaseProfileGuideService : IReleaseProfileGuideService
{
private readonly Lazy<IReadOnlyList<ReleaseProfileData>> _guideData;
public ReleaseProfileGuideService(IRepoMetadataBuilder metadataBuilder, ReleaseProfileGuideParser parser)
{
_guideData = new Lazy<IReadOnlyList<ReleaseProfileData>>(() =>
{
var metadata = metadataBuilder.GetMetadata();
var paths = metadataBuilder.ToDirectoryInfoList(metadata.JsonPaths.Sonarr.ReleaseProfiles);
return parser.GetReleaseProfileData(paths).ToList();
});
}
public IReadOnlyList<ReleaseProfileData> GetReleaseProfileData()
{
return _guideData.Value;
}
}

@ -1,24 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Recyclarr.Json;
namespace Recyclarr.TrashGuide.ReleaseProfile;
public class TermDataConverter : JsonConverter<TermData>
{
public override TermData? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
{
var str = reader.GetString();
return str is not null ? new TermData {Term = str} : null;
}
return JsonSerializer.Deserialize<TermData>(ref reader, options.CopyOptionsWithout<TermDataConverter>());
}
public override void Write(Utf8JsonWriter writer, TermData value, JsonSerializerOptions options)
{
JsonSerializer.Serialize(writer, value, options.CopyOptionsWithout<TermDataConverter>());
}
}

@ -25,14 +25,4 @@ public class ListCommandsTest
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
[Test, AutoMockData]
public async Task Repo_update_is_called_on_list_release_profiles(
[Frozen] IMultiRepoUpdater updater,
ListReleaseProfilesCommand sut)
{
await sut.ExecuteAsync(default!, new ListReleaseProfilesCommand.CliSettings());
await updater.ReceivedWithAnyArgs().UpdateAllRepositories(default);
}
}

@ -1,19 +0,0 @@
# First Release Profile
Do check mark include preferred when renaming
This score is negative [-1]
```
abc
```
# Second Release Profile
Do not check mark include preferred when renaming
This score is positive [1]
```
xyz
```

@ -1,13 +0,0 @@
# Test Release Profile
This score is negative [-1]
```
abc
```
This score is positive [0]
```
xyz
```

@ -1,22 +0,0 @@
### Release Profile 1
The score is [100]
```
term1
```
This is another Score that should not be used [200]
#### Must not contain
```
term2
term3
```
#### Must contain
```
term4
```

@ -1,207 +0,0 @@
using Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Tests.Pipelines.ReleaseProfile.Filters;
[TestFixture]
public class ReleaseProfileDataFiltererTest
{
[Test, AutoMockData]
public void Include_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
};
var result = sut.IncludeTerms(terms, filter);
result.Should().BeEquivalentTo(new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"}
});
}
[Test, AutoMockData]
public void Include_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
};
var result = sut.IncludeTerms(terms, filter);
result.Should().BeEquivalentTo(new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"}
}
}
});
}
[Test, AutoMockData]
public void Exclude_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
};
var result = sut.ExcludeTerms(terms, filter);
result.Should().BeEquivalentTo(new TermData[]
{
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
});
}
[Test, AutoMockData]
public void Exclude_preferred_terms_filter_works(ReleaseProfileDataFilterer sut)
{
var filter = new[] {"1", "2"};
var terms = new PreferredTermData[]
{
new()
{
Score = 10,
Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"},
new() {TrashId = "2", Term = "term2"},
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
}
},
new()
{
Score = 20,
Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"},
new() {Term = "term6"},
new() {Term = "term7"}
}
}
};
var result = sut.ExcludeTerms(terms, filter);
result.Should().BeEquivalentTo(new PreferredTermData[]
{
new()
{
Score = 10,
Terms = new TermData[]
{
new() {TrashId = "3", Term = "term3"},
new() {Term = "term4"},
new() {Term = "term5"}
}
},
new()
{
Score = 20,
Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"},
new() {Term = "term6"},
new() {Term = "term7"}
}
}
});
}
[Test, AutoMockData]
public void Filter_profile_data_with_invalid_terms(ReleaseProfileDataFilterer sut)
{
var profileData = new ReleaseProfileData
{
Preferred = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "1", Term = "term1"}, // excluded by filter
new() {TrashId = "2", Term = ""}, // excluded because it's invalid
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
}
};
var filter = new SonarrProfileFilterConfig
{
Exclude = new[] {"1"}
};
var result = sut.FilterProfile(profileData, filter);
result.Should().BeEquivalentTo(new ReleaseProfileData
{
Preferred = new PreferredTermData[]
{
new()
{
Score = 10, Terms = new TermData[]
{
new() {TrashId = "3", Term = "term3"}
}
},
new()
{
Score = 20, Terms = new TermData[]
{
new() {TrashId = "4", Term = "term4"}
}
}
}
});
}
}

@ -1,55 +0,0 @@
using Recyclarr.Cli.Pipelines.ReleaseProfile.Filters;
using Recyclarr.Config.Models;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Cli.Tests.Pipelines.ReleaseProfile.Filters;
[TestFixture]
public class StrictNegativeScoresFilterTest
{
private static readonly ReleaseProfileData TestProfile = new()
{
Preferred = new[]
{
new PreferredTermData
{
Score = -1,
Terms = new[]
{
new TermData
{
TrashId = "abc",
Term = "a"
}
}
}
}
};
[Test, AutoMockData]
public void Preferred_with_negative_scores_is_treated_as_ignored_when_strict_negative_scores_enabled(
StrictNegativeScoresFilter sut)
{
var config = new ReleaseProfileConfig
{
StrictNegativeScores = true
};
var result = sut.Transform(TestProfile, config);
result.Preferred.Should().BeEmpty();
result.Ignored.Should().BeEquivalentTo(TestProfile.Preferred.First().Terms);
}
[Test, AutoMockData]
public void Preferred_and_ignored_untouched_when_strict_negative_scores_disabled(StrictNegativeScoresFilter sut)
{
var config = new ReleaseProfileConfig
{
StrictNegativeScores = false
};
var result = sut.Transform(TestProfile, config);
result.Should().BeSameAs(TestProfile);
}
}

@ -1,80 +0,0 @@
using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.TrashGuide.ReleaseProfile;
using Spectre.Console.Testing;
namespace Recyclarr.Cli.Tests.Pipelines.ReleaseProfile;
[TestFixture]
public class ReleaseProfileDataListerTest
{
[Test, AutoMockData]
public void Release_profiles_appear_in_console_output(
[Frozen(Matching.ImplementedInterfaces)] TestConsole console,
[Frozen] IReleaseProfileGuideService guide,
ReleaseProfileDataLister sut)
{
var testData = new[]
{
new ReleaseProfileData {Name = "First", TrashId = "123"},
new ReleaseProfileData {Name = "Second", TrashId = "456"}
};
guide.GetReleaseProfileData().Returns(testData);
sut.ListReleaseProfiles();
console.Output.Should().ContainAll(
testData.SelectMany(x => new[] {x.Name, x.TrashId}));
}
[Test, AutoMockData]
public void Terms_appear_in_console_output(
[Frozen] IReleaseProfileGuideService guide,
[Frozen(Matching.ImplementedInterfaces)] TestConsole console,
ReleaseProfileDataLister sut)
{
var requiredData = new[]
{
new TermData {Name = "First", TrashId = "111", Term = "term1"},
new TermData {Name = "Second", TrashId = "222", Term = "term2"}
};
var ignoredData = new[]
{
new TermData {Name = "Third", TrashId = "333", Term = "term3"},
new TermData {Name = "Fourth", TrashId = "444", Term = "term4"}
};
var preferredData = new[]
{
new TermData {Name = "Fifth", TrashId = "555", Term = "term5"},
new TermData {Name = "Sixth", TrashId = "666", Term = "term6"}
};
guide.GetReleaseProfileData().Returns(new[]
{
new ReleaseProfileData
{
Name = "Release Profile",
TrashId = "098",
Required = requiredData,
Ignored = ignoredData,
Preferred = new PreferredTermData[]
{
new() {Score = 100, Terms = preferredData}
}
}
});
sut.ListTerms("098");
var expectedOutput = new[]
{
requiredData.SelectMany(x => new[] {x.Name, x.TrashId}),
ignoredData.SelectMany(x => new[] {x.Name, x.TrashId}),
preferredData.SelectMany(x => new[] {x.Name, x.TrashId})
};
console.Output.Should().ContainAll(expectedOutput.SelectMany(x => x));
}
}

@ -1,29 +0,0 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
[TestFixture]
public class TagApiFetchPhaseTest
{
[Test, AutoMockData]
public async Task Cache_is_updated(
[Frozen] ISonarrTagApiService api,
[Frozen] ServiceTagCache cache,
TagPipelineContext context,
TagApiFetchPhase sut)
{
var expectedData = new[]
{
new SonarrTag {Id = 3},
new SonarrTag {Id = 4},
new SonarrTag {Id = 5}
};
api.GetTags(default!).ReturnsForAnyArgs(expectedData);
await sut.Execute(context, default!);
cache.Tags.Should().BeEquivalentTo(expectedData);
}
}

@ -1,42 +0,0 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
[TestFixture]
public class TagApiPersistencePhaseTest
{
[Test, AutoMockData]
public async Task Persisted_tags_are_added_to_cache(
[Frozen] ISonarrTagApiService api,
[Frozen] ServiceTagCache cache,
TagApiPersistencePhase sut)
{
cache.AddTags(new[]
{
new SonarrTag {Id = 1},
new SonarrTag {Id = 2}
});
var config = Substitute.For<IServiceConfiguration>();
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(context, config);
cache.Tags.Should().BeEquivalentTo(new[]
{
new SonarrTag {Id = 1},
new SonarrTag {Id = 2},
new SonarrTag {Id = 3},
new SonarrTag {Id = 4}
});
}
}

@ -1,42 +0,0 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary;
namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
[TestFixture]
public class TagConfigPhaseTest
{
[Test, AutoMockData]
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>()
};
await sut.Execute(context, config);
context.ConfigOutput.Should().BeEmpty();
}
[Test, AutoMockData]
public void Output_not_empty_when_config_has_tags(TagConfigPhase sut)
{
var config = NewConfig.Sonarr() with
{
ReleaseProfiles = new[]
{
new ReleaseProfileConfig
{
Tags = new[] {"one", "two", "three"}
}
}
};
var context = new TagPipelineContext();
sut.Execute(context, config);
context.ConfigOutput.Should().BeEquivalentTo(config.ReleaseProfiles[0].Tags);
}
}

@ -1,60 +0,0 @@
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Pipelines.Tags.PipelinePhases;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.Cli.Tests.Pipelines.Tags.PipelinePhases;
[TestFixture]
public class TagTransactionPhaseTest
{
[Test, AutoMockData]
public void Return_tags_in_config_that_do_not_exist_in_service(TagTransactionPhase sut)
{
var context = new TagPipelineContext
{
ConfigOutput = new[] {"one", "two", "three"},
ApiFetchOutput = new[]
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
}
};
sut.Execute(context, Substitute.For<IServiceConfiguration>());
context.TransactionOutput.Should().BeEquivalentTo("one", "two");
}
[Test, AutoMockData]
public void Return_all_tags_if_none_exist(TagTransactionPhase sut)
{
var context = new TagPipelineContext
{
ConfigOutput = new[] {"one", "two", "three"},
ApiFetchOutput = Array.Empty<SonarrTag>()
};
sut.Execute(context, Substitute.For<IServiceConfiguration>());
context.TransactionOutput.Should().BeEquivalentTo("one", "two", "three");
}
[Test, AutoMockData]
public void No_tags_returned_if_all_exist(TagTransactionPhase sut)
{
var context = new TagPipelineContext
{
ConfigOutput = Array.Empty<string>(),
ApiFetchOutput = new[]
{
new SonarrTag {Label = "three"},
new SonarrTag {Label = "four"}
}
};
sut.Execute(context, Substitute.For<IServiceConfiguration>());
context.TransactionOutput.Should().BeEmpty();
}
}

@ -19,7 +19,7 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
instance1:
api_key: !secret api_key
base_url: !secret 123GARBAGE_
release_profiles:
custom_formats:
- trash_ids:
- !secret secret_rp
""";
@ -39,7 +39,7 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
InstanceName = "instance1",
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("https://radarr:7878"),
ReleaseProfiles = new[]
CustomFormats = new[]
{
new
{
@ -123,10 +123,10 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
instance5:
api_key: fake_key
base_url: fake_url
release_profiles: !secret bogus_profile
custom_formats: !secret bogus_profile
""";
const string secretsYml = @"bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
const string secretsYml = "bogus_profile: 95283e6b156c42f3af8a9b16173f876b";
Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
configLoader.Load(() => new StringReader(testYml))

@ -101,26 +101,7 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("http://localhost:8989"),
InstanceName = "name",
ReplaceExistingCustomFormats = false,
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
{
TrashIds = new[] {"123"},
StrictNegativeScores = true,
Tags = new List<string> {"anime"}
},
new()
{
TrashIds = new[] {"456"},
StrictNegativeScores = false,
Tags = new List<string>
{
"tv",
"series"
}
}
}
ReplaceExistingCustomFormats = false
}
});
}

@ -2,12 +2,3 @@
name:
base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b
release_profiles:
- trash_ids: [123]
strict_negative_scores: true
tags:
- anime
- trash_ids: [456]
tags:
- tv
- series

@ -1,6 +1,5 @@
using Recyclarr.Compatibility;
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary;
namespace Recyclarr.Tests.Compatibility.Sonarr;
@ -16,88 +15,11 @@ public class SonarrCapabilityEnforcerTest
var config = NewConfig.Sonarr();
var min = SonarrCapabilities.MinimumVersion;
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
Version = new Version(min.Major, min.Minor, min.Build, min.Revision - 1)
});
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(
new SonarrCapabilities(new Version(min.Major - 1, min.Minor, min.Build, min.Revision)));
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*minimum*");
}
[Test, AutoMockData]
public void Release_profiles_not_allowed_in_v4(
[Frozen] ISonarrCapabilityFetcher fetcher,
SonarrCapabilityEnforcer sut)
{
var config = NewConfig.Sonarr() with
{
ReleaseProfiles = new List<ReleaseProfileConfig>
{
new()
}
};
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = true
});
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*v3*");
}
[Test, AutoMockData]
public void Custom_formats_not_allowed_in_v3(
[Frozen] ISonarrCapabilityFetcher fetcher,
SonarrCapabilityEnforcer sut)
{
var config = NewConfig.Sonarr() with
{
CustomFormats = new List<CustomFormatConfig>
{
new()
}
};
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = false
});
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*custom formats*v4*");
}
[Test, AutoMockData]
public void Qualities_not_allowed_in_v3(
[Frozen] ISonarrCapabilityFetcher fetcher,
SonarrCapabilityEnforcer sut)
{
var config = NewConfig.Sonarr() with
{
QualityProfiles = new[]
{
new QualityProfileConfig
{
Qualities = new[]
{
new QualityProfileQualityConfig()
}
}
}
};
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = false
});
var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*qualities*v4*");
}
}

@ -1,130 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
public class MergeReleaseProfilesTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ReleaseProfiles = new[]
{
new ReleaseProfileConfigYaml
{
TrashIds = new[] {"id1"},
Filter = new ReleaseProfileFilterConfigYaml
{
Exclude = new[] {"exclude"},
Include = new[] {"include"}
},
Tags = new[] {"tag1", "tag2"},
StrictNegativeScores = true
}
}
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
var rightConfig = new SonarrConfigYaml
{
ReleaseProfiles = new[]
{
new ReleaseProfileConfigYaml
{
TrashIds = new[] {"id1"},
Filter = new ReleaseProfileFilterConfigYaml
{
Exclude = new[] {"exclude"},
Include = new[] {"include"}
},
Tags = new[] {"tag1", "tag2"},
StrictNegativeScores = true
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
[Test]
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")]
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ReleaseProfiles = new[]
{
new ReleaseProfileConfigYaml
{
TrashIds = new[] {"id1"},
Filter = new ReleaseProfileFilterConfigYaml
{
Exclude = new[] {"exclude1"},
Include = new[] {"include1"}
},
Tags = new[] {"tag1", "tag2"},
StrictNegativeScores = true
},
new ReleaseProfileConfigYaml
{
TrashIds = new[] {"id2", "id3"},
Filter = new ReleaseProfileFilterConfigYaml
{
Exclude = new[] {"exclude2"},
Include = new[] {"include2"}
},
Tags = new[] {"tag3"},
StrictNegativeScores = true
}
}
};
var rightConfig = new SonarrConfigYaml
{
ReleaseProfiles = new[]
{
new ReleaseProfileConfigYaml
{
TrashIds = new[] {"id4"},
Filter = new ReleaseProfileFilterConfigYaml
{
Exclude = new[] {"exclude3"},
Include = new[] {"include3"}
},
Tags = new[] {"tag4", "tag5"},
StrictNegativeScores = false
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
ReleaseProfiles = leftConfig.ReleaseProfiles.Concat(rightConfig.ReleaseProfiles).ToList()
});
}
}

@ -1,84 +0,0 @@
using FluentValidation.TestHelper;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
namespace Recyclarr.Tests.Config.Parsing;
[TestFixture]
public class SonarrConfigYamlValidatorTest
{
[Test]
public void Validation_failure_when_rps_and_cfs_used_together()
{
var config = new SonarrConfigYaml
{
ReleaseProfiles = new[] {new ReleaseProfileConfigYaml()},
CustomFormats = new[] {new CustomFormatConfigYaml()}
};
var validator = new SonarrConfigYamlValidator();
var result = validator.TestValidate(config);
result.ShouldHaveValidationErrorFor(x => x)
.WithErrorMessage("`custom_formats` and `release_profiles` may not be used together");
}
[Test]
public void Sonarr_release_profile_failures()
{
var config = new ReleaseProfileConfigYaml
{
TrashIds = Array.Empty<string>(),
Filter = new ReleaseProfileFilterConfigYaml
{
Include = new[] {"include"},
Exclude = new[] {"exclude"}
}
};
var validator = new ReleaseProfileConfigYamlValidator();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(2);
// Release profile trash IDs cannot be empty
result.ShouldHaveValidationErrorFor(x => x.TrashIds);
// Cannot use include + exclude filters together
result.ShouldHaveValidationErrorFor(nameof(ReleaseProfileConfig.Filter));
}
[Test]
public void Filter_include_can_not_be_empty()
{
var config = new ReleaseProfileFilterConfigYaml
{
Include = Array.Empty<string>(),
Exclude = new[] {"exclude"}
};
var validator = new ReleaseProfileFilterConfigYamlValidator();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(1);
result.ShouldHaveValidationErrorFor(x => x.Include);
}
[Test]
public void Filter_exclude_can_not_be_empty()
{
var config = new ReleaseProfileFilterConfigYaml
{
Exclude = Array.Empty<string>(),
Include = new[] {"exclude"}
};
var validator = new ReleaseProfileFilterConfigYamlValidator();
var result = validator.TestValidate(config);
result.Errors.Should().HaveCount(1);
result.ShouldHaveValidationErrorFor(x => x.Exclude);
}
}

@ -17,7 +17,6 @@ public class TrashRepoMetadataBuilderTest
"naming": ["docs/json/radarr/naming"]
},
"sonarr": {
"release_profiles": ["docs/json/sonarr/rp"],
"custom_formats": ["docs/json/sonarr/cf"],
"qualities": ["docs/json/sonarr/quality-size"],
"naming": ["docs/json/sonarr/naming"]

@ -1,74 +0,0 @@
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Tests.TrashGuide.ReleaseProfile;
[TestFixture]
public class ReleaseProfileDataValidationFiltererTest
{
[Test, AutoMockData]
public void Valid_data_is_not_filtered_out(ReleaseProfileDataValidationFilterer sut)
{
var data = new[]
{
new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = new[] {new TermData {Term = "term1"}},
Ignored = new[] {new TermData {Term = "term2"}},
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData {Term = "term3"}}}}
}
};
var result = sut.FilterProfiles(data);
result.Should().BeEquivalentTo(data);
}
[Test, AutoMockData]
public void Invalid_terms_are_filtered_out(ReleaseProfileDataValidationFilterer sut)
{
var data = new[]
{
new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = new[] {new TermData {Term = ""}},
Ignored = new[] {new TermData {Term = "term2"}},
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData {Term = "term3"}}}}
}
};
var result = sut.FilterProfiles(data);
result.Should().ContainSingle().Which.Should().BeEquivalentTo(new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = Array.Empty<TermData>(),
Ignored = new[] {new TermData {Term = "term2"}},
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData {Term = "term3"}}}}
});
}
[Test, AutoMockData]
public void Whole_release_profile_filtered_out_if_all_terms_invalid(ReleaseProfileDataValidationFilterer sut)
{
var data = new[]
{
new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = new[] {new TermData {Term = ""}},
Ignored = new[] {new TermData {Term = ""}},
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData {Term = ""}}}}
}
};
var result = sut.FilterProfiles(data);
result.Should().BeEmpty();
}
}

@ -1,95 +0,0 @@
using FluentValidation.TestHelper;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Tests.TrashGuide.ReleaseProfile;
[TestFixture]
public class ReleaseProfileDataValidatorTest
{
[Test]
public void Empty_term_collections_not_allowed()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData();
validator.Validate(data).IsValid.Should().BeFalse();
}
[Test]
public void Allow_single_preferred_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = Array.Empty<TermData>(),
Ignored = Array.Empty<TermData>(),
Preferred = new[] {new PreferredTermData {Terms = new[] {new TermData()}}}
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Allow_single_required_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = new[] {new TermData {Term = "term"}},
Ignored = Array.Empty<TermData>(),
Preferred = Array.Empty<PreferredTermData>()
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Allow_single_ignored_term()
{
var validator = new ReleaseProfileDataValidator();
var data = new ReleaseProfileData
{
TrashId = "trash_id",
Name = "name",
Required = Array.Empty<TermData>(),
Ignored = new[] {new TermData {Term = "term"}},
Preferred = Array.Empty<PreferredTermData>()
};
var result = validator.TestValidate(data);
result.ShouldNotHaveAnyValidationErrors();
}
[Test]
public void Term_data_validate_empty()
{
var validator = new TermDataValidator();
var data = new TermData();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Term);
result.ShouldNotHaveValidationErrorFor(x => x.Name);
result.ShouldNotHaveValidationErrorFor(x => x.TrashId);
}
[Test]
public void Preferred_term_data_validate_empty()
{
var validator = new PreferredTermDataValidator();
var data = new PreferredTermData();
var result = validator.TestValidate(data);
result.ShouldHaveValidationErrorFor(x => x.Terms);
}
}

@ -1,85 +0,0 @@
using System.IO.Abstractions;
using Recyclarr.Json;
using Recyclarr.Repo;
using Recyclarr.TestLibrary;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.Tests.TrashGuide.ReleaseProfile;
[TestFixture]
public class ReleaseProfileGuideServiceTest
{
[Test, AutoMockData]
public void Get_release_profile_json_works(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IRepoMetadataBuilder metadataBuilder,
ReleaseProfileGuideService sut)
{
static ReleaseProfileData MakeMockObject(string term)
{
return new ReleaseProfileData
{
Name = "name",
TrashId = "123",
Required = new TermData[]
{
new() {Term = term}
}
};
}
var mockData1 = MakeMockObject("first");
var mockData2 = MakeMockObject("second");
var baseDir = fs.CurrentDirectory().SubDirectory("files");
baseDir.Create();
fs.AddFile(baseDir.File("first.json").FullName,
MockData.FromJson(mockData1, GlobalJsonSerializerSettings.Services));
fs.AddFile(baseDir.File("second.json").FullName,
MockData.FromJson(mockData2, GlobalJsonSerializerSettings.Services));
metadataBuilder.ToDirectoryInfoList(default!).ReturnsForAnyArgs(new[] {baseDir});
var results = sut.GetReleaseProfileData();
results.Should().BeEquivalentTo(new[]
{
mockData1,
mockData2
});
}
[Test, AutoMockData]
public void Json_exceptions_do_not_interrupt_parsing_other_files(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IRepoMetadataBuilder metadataBuilder,
ReleaseProfileGuideService sut)
{
var rootPath = fs.CurrentDirectory().SubDirectory("files");
rootPath.Create();
var badData = "# comment";
var goodData = new ReleaseProfileData
{
Name = "name",
TrashId = "123",
Required = new TermData[]
{
new() {Term = "abc"}
}
};
fs.AddFile(rootPath.File("0_bad_data.json").FullName,
MockData.FromString(badData));
fs.AddFile(rootPath.File("1_good_data.json").FullName,
MockData.FromJson(goodData, GlobalJsonSerializerSettings.Services));
metadataBuilder.ToDirectoryInfoList(default!).ReturnsForAnyArgs(new[] {rootPath});
var results = sut.GetReleaseProfileData();
results.Should().BeEquivalentTo(new[] {goodData});
}
}
Loading…
Cancel
Save