feat!: Remove Sonarr v3 Support

Includes complete removal of Release Profile support.
pull/231/head
Robert Dailey 2 months 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 - **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 Support/recyclarr` instead of `~/.config/recyclarr`. Users will need to run `recyclarr migrate` to
move the directory (or do it manually). 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: 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 ### Fixed

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

@ -111,9 +111,6 @@
"include": { "include": {
"$ref": "config/includes.json" "$ref": "config/includes.json"
}, },
"release_profiles": {
"$ref": "config/release-profiles.json"
},
"media_naming": { "media_naming": {
"$ref": "config/media-naming-sonarr.json" "$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.MediaNaming;
using Recyclarr.Cli.Pipelines.QualityProfile; using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualitySize; using Recyclarr.Cli.Pipelines.QualitySize;
using Recyclarr.Cli.Pipelines.ReleaseProfile;
using Recyclarr.Cli.Pipelines.Tags;
using Recyclarr.Cli.Processors; using Recyclarr.Cli.Processors;
using Recyclarr.Common; using Recyclarr.Common;
using Recyclarr.Compatibility; using Recyclarr.Compatibility;
@ -69,22 +67,18 @@ public static class CompositionRoot
private static void PipelineRegistrations(ContainerBuilder builder) private static void PipelineRegistrations(ContainerBuilder builder)
{ {
builder.RegisterModule<TagsAutofacModule>();
builder.RegisterModule<CustomFormatAutofacModule>(); builder.RegisterModule<CustomFormatAutofacModule>();
builder.RegisterModule<QualityProfileAutofacModule>(); builder.RegisterModule<QualityProfileAutofacModule>();
builder.RegisterModule<QualitySizeAutofacModule>(); builder.RegisterModule<QualitySizeAutofacModule>();
builder.RegisterModule<ReleaseProfileAutofacModule>();
builder.RegisterModule<MediaNamingAutofacModule>(); builder.RegisterModule<MediaNamingAutofacModule>();
builder.RegisterGeneric(typeof(GenericPipelinePhases<>)); builder.RegisterGeneric(typeof(GenericPipelinePhases<>));
builder.RegisterTypes( builder.RegisterTypes(
// ORDER HERE IS IMPORTANT! // ORDER HERE IS IMPORTANT!
// There are indirect dependencies between pipelines. // There are indirect dependencies between pipelines.
typeof(GenericSyncPipeline<TagPipelineContext>),
typeof(GenericSyncPipeline<CustomFormatPipelineContext>), typeof(GenericSyncPipeline<CustomFormatPipelineContext>),
typeof(GenericSyncPipeline<QualityProfilePipelineContext>), typeof(GenericSyncPipeline<QualityProfilePipelineContext>),
typeof(GenericSyncPipeline<QualitySizePipelineContext>), typeof(GenericSyncPipeline<QualitySizePipelineContext>),
typeof(GenericSyncPipeline<ReleaseProfilePipelineContext>),
typeof(GenericSyncPipeline<MediaNamingPipelineContext>)) typeof(GenericSyncPipeline<MediaNamingPipelineContext>))
.As<ISyncPipeline>() .As<ISyncPipeline>()
.OrderByRegistration(); .OrderByRegistration();

@ -18,7 +18,6 @@ public static class CliSetup
{ {
list.SetDescription("List information from the guide"); list.SetDescription("List information from the guide");
list.AddCommand<ListCustomFormatsCommand>("custom-formats"); list.AddCommand<ListCustomFormatsCommand>("custom-formats");
list.AddCommand<ListReleaseProfilesCommand>("release-profiles");
list.AddCommand<ListQualitiesCommand>("qualities"); list.AddCommand<ListQualitiesCommand>("qualities");
list.AddCommand<ListMediaNamingCommand>("naming"); 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, IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey, string? configFormatKey,
string errorDescription) 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) if (configFormatKey is null)
{ {
@ -29,11 +20,6 @@ public class NamingFormatLookup
var lowerKey = configFormatKey.ToLowerInvariant(); var lowerKey = configFormatKey.ToLowerInvariant();
var keys = new List<string> {lowerKey}; 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) foreach (var k in keys)
{ {

@ -1,43 +1,36 @@
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming; using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.TrashGuide.MediaNaming; using Recyclarr.TrashGuide.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases.Config; namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases.Config;
public class SonarrMediaNamingConfigPhase(ISonarrCapabilityFetcher sonarrCapabilities) public class SonarrMediaNamingConfigPhase : ServiceBasedMediaNamingConfigPhase<SonarrConfiguration>
: ServiceBasedMediaNamingConfigPhase<SonarrConfiguration>
{ {
protected override async Task<MediaNamingDto> ProcessNaming( protected override Task<MediaNamingDto> ProcessNaming(
SonarrConfiguration config, SonarrConfiguration config,
IMediaNamingGuideService guide, IMediaNamingGuideService guide,
NamingFormatLookup lookup) NamingFormatLookup lookup)
{ {
var guideData = guide.GetSonarrNamingData(); var guideData = guide.GetSonarrNamingData();
var configData = config.MediaNaming; 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"), SeasonFolderFormat = lookup.ObtainFormat(guideData.Season, configData.Season, "Season Folder Format"),
SeriesFolderFormat = lookup.ObtainFormat(guideData.Series, configData.Series, "Series Folder Format"), SeriesFolderFormat = lookup.ObtainFormat(guideData.Series, configData.Series, "Series Folder Format"),
StandardEpisodeFormat = lookup.ObtainFormat( StandardEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Standard, guideData.Episodes.Standard,
configData.Episodes?.Standard, configData.Episodes?.Standard,
keySuffix,
"Standard Episode Format"), "Standard Episode Format"),
DailyEpisodeFormat = lookup.ObtainFormat( DailyEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Daily, guideData.Episodes.Daily,
configData.Episodes?.Daily, configData.Episodes?.Daily,
keySuffix,
"Daily Episode Format"), "Daily Episode Format"),
AnimeEpisodeFormat = lookup.ObtainFormat( AnimeEpisodeFormat = lookup.ObtainFormat(
guideData.Episodes.Anime, guideData.Episodes.Anime,
configData.Episodes?.Anime, configData.Episodes?.Anime,
keySuffix,
"Anime Episode Format"), "Anime Episode Format"),
RenameEpisodes = configData.Episodes?.Rename 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 System.Diagnostics.CodeAnalysis;
using Recyclarr.Cli.Console; using Recyclarr.Cli.Console;
using Recyclarr.Cli.Console.Settings; using Recyclarr.Cli.Console.Settings;
using Recyclarr.Compatibility;
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config; using Recyclarr.Config;
using Recyclarr.Config.Models; using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.CustomFormat; using Recyclarr.ServarrApi.CustomFormat;
@ -15,16 +13,13 @@ public class DeleteCustomFormatsProcessor(
ILogger log, ILogger log,
IAnsiConsole console, IAnsiConsole console,
ICustomFormatApiService api, ICustomFormatApiService api,
IConfigurationRegistry configRegistry, IConfigurationRegistry configRegistry)
ISonarrCapabilityFetcher sonarCapabilities)
: IDeleteCustomFormatsProcessor : IDeleteCustomFormatsProcessor
{ {
public async Task Process(IDeleteCustomFormatSettings settings) public async Task Process(IDeleteCustomFormatSettings settings)
{ {
var config = GetTargetConfig(settings); var config = GetTargetConfig(settings);
await CheckCustomFormatSupport(config);
var cfs = await ObtainCustomFormats(config); var cfs = await ObtainCustomFormats(config);
if (!settings.All) if (!settings.All)
@ -61,18 +56,6 @@ public class DeleteCustomFormatsProcessor(
await DeleteCustomFormats(cfs, config); 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")] [SuppressMessage("Design", "CA1031:Do not catch general exception types")]
private async Task DeleteCustomFormats(ICollection<CustomFormatData> cfs, IServiceConfiguration config) 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 # 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 # An empty starter config to use with Recyclarr. Update the values below as needed for your
# values below as needed for your instance. You will be required to update the API Key and URL for # instance. You will be required to update the `api_key` and `base_url` for each instance you want
# each instance you want to use. # to use.
# #
# Many optional settings have been omitted to keep this template simple. Note that there's no "one # If you'd rather use pre-built configuration instead of building your own from scratch, see these
# size fits all" configuration. Please refer to the guide to understand how to build the appropriate # pages:
# configuration based on your hardware setup and capabilities. # - 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 (`#`). # This file WILL NOT WORK as it currently is. You need to read the documentation and build this
# The YAML comments will already be at the appropriate indentation. # 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: # For more details on the configuration, see the Configuration Reference on the wiki here:
# https://recyclarr.dev/wiki/yaml/config-reference/ # 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: sonarr:
series: series:
# Set the URL/API Key to your actual instance # Set the URL/API Key to your actual instance
base_url: http://localhost:8989 base_url: http://localhost:8989
api_key: YOUR_KEY_HERE api_key: YOUR_KEY_HERE
# Quality definitions from the guide to sync to Sonarr. Choices: series, anime # See: https://recyclarr.dev/wiki/yaml/config-reference/quality-definition/
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'
quality_definition: quality_definition:
type: movie
# Set to 'true' to automatically remove custom formats from Radarr when they are removed from # See: http://localhost:3000/wiki/yaml/config-reference/quality-profiles/
# the guide or your configuration. This will NEVER delete custom formats you manually created! quality_profiles:
delete_old_custom_formats: false
# See: http://localhost:3000/wiki/yaml/config-reference/custom-formats/
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 # See: http://localhost:3000/wiki/yaml/config-reference/media-naming/
# updated with scores from the guide for each custom format. Without this, custom formats media_naming:
# 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.

@ -2,9 +2,16 @@ namespace Recyclarr.Compatibility.Sonarr;
public record SonarrCapabilities 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; using Recyclarr.Config.Models;
namespace Recyclarr.Compatibility.Sonarr; namespace Recyclarr.Compatibility.Sonarr;
@ -15,36 +14,5 @@ public class SonarrCapabilityEnforcer(ISonarrCapabilityFetcher capabilityFetcher
$"Your Sonarr version {capabilities.Version} does not meet the minimum " + $"Your Sonarr version {capabilities.Version} does not meet the minimum " +
$"required version of {SonarrCapabilities.MinimumVersion}."); $"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 return new SonarrCapabilities
{ {
Version = version, Version = version
SupportsCustomFormats =
version >= new Version(4, 0)
}; };
} }
} }

@ -25,7 +25,7 @@ public static class ConfigExtensions
this IEnumerable<IServiceConfiguration> configs, this IEnumerable<IServiceConfiguration> configs,
ConfigFilterCriteria criteria) 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. // ".Where()" in the LINQ expression below.
return configs.GetConfigsOfType(criteria.Service) return configs.GetConfigsOfType(criteria.Service)
.Where(x => criteria.Instances.IsEmpty() || .Where(x => criteria.Instances.IsEmpty() ||

@ -6,26 +6,9 @@ public record SonarrConfiguration : ServiceConfiguration
{ {
public override SupportedServices ServiceType => SupportedServices.Sonarr; public override SupportedServices ServiceType => SupportedServices.Sonarr;
public IList<ReleaseProfileConfig> ReleaseProfiles { get; init; } =
Array.Empty<ReleaseProfileConfig>();
public SonarrMediaNamingConfig MediaNaming { get; init; } = new(); 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 record SonarrMediaNamingConfig
{ {
public string? Season { get; init; } public string? Season { get; init; }

@ -2,22 +2,6 @@ using JetBrains.Annotations;
namespace Recyclarr.Config.Parsing; 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)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrEpisodeNamingConfigYaml public record SonarrEpisodeNamingConfigYaml
{ {
@ -38,6 +22,5 @@ public record SonarrMediaNamingConfigYaml
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrConfigYaml : ServiceConfigYaml public record SonarrConfigYaml : ServiceConfigYaml
{ {
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; init; }
public SonarrMediaNamingConfigYaml? MediaNaming { get; init; } public SonarrMediaNamingConfigYaml? MediaNaming { get; init; }
} }

@ -206,45 +206,6 @@ public class SonarrConfigYamlValidator : CustomValidator<SonarrConfigYaml>
public SonarrConfigYamlValidator() public SonarrConfigYamlValidator()
{ {
Include(new ServiceConfigYamlValidator()); 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<QualityScoreConfigYaml, QualityProfileScoreConfig>();
CreateMap<CustomFormatConfigYaml, CustomFormatConfig>(); CreateMap<CustomFormatConfigYaml, CustomFormatConfig>();
CreateMap<QualitySizeConfigYaml, QualityDefinitionConfig>(); CreateMap<QualitySizeConfigYaml, QualityDefinitionConfig>();
CreateMap<ReleaseProfileConfigYaml, ReleaseProfileConfig>();
CreateMap<ReleaseProfileFilterConfigYaml, SonarrProfileFilterConfig>();
CreateMap<ResetUnmatchedScoresConfigYaml, ResetUnmatchedScoresConfig>(); CreateMap<ResetUnmatchedScoresConfigYaml, ResetUnmatchedScoresConfig>();
CreateMap<RadarrMediaNamingConfigYaml, RadarrMediaNamingConfig>(); CreateMap<RadarrMediaNamingConfigYaml, RadarrMediaNamingConfig>();

@ -34,6 +34,14 @@ public static class ConfigContextualMessages
"See: https://recyclarr.dev/wiki/upgrade-guide/v6.0/#reset-scores"; "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; return null;
} }
} }

@ -8,9 +8,6 @@ public class SonarrConfigMerger : ServiceConfigMerger<SonarrConfigYaml>
{ {
return base.Merge(a, b) with 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) MediaNaming = Combine(a.MediaNaming, b.MediaNaming, MergeMediaNaming)
}; };
} }

@ -9,7 +9,6 @@ public record RadarrMetadata
public record SonarrMetadata public record SonarrMetadata
{ {
public IReadOnlyCollection<string> ReleaseProfiles { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Qualities { 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> CustomFormats { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Naming { 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.QualityDefinition;
using Recyclarr.ServarrApi.QualityProfile; using Recyclarr.ServarrApi.QualityProfile;
using Recyclarr.ServarrApi.System; using Recyclarr.ServarrApi.System;
using Recyclarr.ServarrApi.Tag;
namespace Recyclarr.ServarrApi; namespace Recyclarr.ServarrApi;
@ -26,7 +25,6 @@ public class ApiServicesAutofacModule : Module
builder.RegisterType<CustomFormatApiService>().As<ICustomFormatApiService>(); builder.RegisterType<CustomFormatApiService>().As<ICustomFormatApiService>();
builder.RegisterType<QualityDefinitionApiService>().As<IQualityDefinitionApiService>(); builder.RegisterType<QualityDefinitionApiService>().As<IQualityDefinitionApiService>();
builder.RegisterType<MediaNamingApiService>().As<IMediaNamingApiService>(); builder.RegisterType<MediaNamingApiService>().As<IMediaNamingApiService>();
builder.RegisterType<SonarrTagApiService>().As<ISonarrTagApiService>();
builder.RegisterTypes( builder.RegisterTypes(
typeof(FlurlAfterCallHandler), 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.CustomFormat;
using Recyclarr.TrashGuide.MediaNaming; using Recyclarr.TrashGuide.MediaNaming;
using Recyclarr.TrashGuide.QualitySize; using Recyclarr.TrashGuide.QualitySize;
using Recyclarr.TrashGuide.ReleaseProfile;
namespace Recyclarr.TrashGuide; namespace Recyclarr.TrashGuide;
@ -19,10 +18,6 @@ public class GuideAutofacModule : Module
builder.RegisterType<CustomFormatLoader>().As<ICustomFormatLoader>(); builder.RegisterType<CustomFormatLoader>().As<ICustomFormatLoader>();
builder.RegisterType<CustomFormatCategoryParser>().As<ICustomFormatCategoryParser>(); builder.RegisterType<CustomFormatCategoryParser>().As<ICustomFormatCategoryParser>();
// Release Profile
builder.RegisterType<ReleaseProfileGuideParser>();
builder.RegisterType<ReleaseProfileGuideService>().As<IReleaseProfileGuideService>().SingleInstance();
// Quality Size // Quality Size
builder.RegisterType<QualitySizeGuideService>().As<IQualitySizeGuideService>().SingleInstance(); builder.RegisterType<QualitySizeGuideService>().As<IQualitySizeGuideService>().SingleInstance();
builder.RegisterType<QualitySizeGuideParser>(); 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); 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: instance1:
api_key: !secret api_key api_key: !secret api_key
base_url: !secret 123GARBAGE_ base_url: !secret 123GARBAGE_
release_profiles: custom_formats:
- trash_ids: - trash_ids:
- !secret secret_rp - !secret secret_rp
"""; """;
@ -39,7 +39,7 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
InstanceName = "instance1", InstanceName = "instance1",
ApiKey = "95283e6b156c42f3af8a9b16173f876b", ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("https://radarr:7878"), BaseUrl = new Uri("https://radarr:7878"),
ReleaseProfiles = new[] CustomFormats = new[]
{ {
new new
{ {
@ -123,10 +123,10 @@ public class ConfigurationLoaderSecretsTest : IntegrationTestFixture
instance5: instance5:
api_key: fake_key api_key: fake_key
base_url: fake_url 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)); Fs.AddFile(Paths.AppDataDirectory.File("recyclarr.yml").FullName, new MockFileData(secretsYml));
configLoader.Load(() => new StringReader(testYml)) configLoader.Load(() => new StringReader(testYml))

@ -101,26 +101,7 @@ public class ConfigurationLoaderTest : IntegrationTestFixture
ApiKey = "95283e6b156c42f3af8a9b16173f876b", ApiKey = "95283e6b156c42f3af8a9b16173f876b",
BaseUrl = new Uri("http://localhost:8989"), BaseUrl = new Uri("http://localhost:8989"),
InstanceName = "name", InstanceName = "name",
ReplaceExistingCustomFormats = false, 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"
}
}
}
} }
}); });
} }

@ -2,12 +2,3 @@
name: name:
base_url: http://localhost:8989 base_url: http://localhost:8989
api_key: 95283e6b156c42f3af8a9b16173f876b 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;
using Recyclarr.Compatibility.Sonarr; using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models;
using Recyclarr.Tests.TestLibrary; using Recyclarr.Tests.TestLibrary;
namespace Recyclarr.Tests.Compatibility.Sonarr; namespace Recyclarr.Tests.Compatibility.Sonarr;
@ -16,88 +15,11 @@ public class SonarrCapabilityEnforcerTest
var config = NewConfig.Sonarr(); var config = NewConfig.Sonarr();
var min = SonarrCapabilities.MinimumVersion; var min = SonarrCapabilities.MinimumVersion;
fetcher.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities fetcher.GetCapabilities(default!).ReturnsForAnyArgs(
{ new SonarrCapabilities(new Version(min.Major - 1, min.Minor, min.Build, min.Revision)));
Version = new Version(min.Major, min.Minor, min.Build, min.Revision - 1)
});
var act = () => sut.Check(config); var act = () => sut.Check(config);
act.Should().ThrowAsync<ServiceIncompatibilityException>().WithMessage("*minimum*"); 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"] "naming": ["docs/json/radarr/naming"]
}, },
"sonarr": { "sonarr": {
"release_profiles": ["docs/json/sonarr/rp"],
"custom_formats": ["docs/json/sonarr/cf"], "custom_formats": ["docs/json/sonarr/cf"],
"qualities": ["docs/json/sonarr/quality-size"], "qualities": ["docs/json/sonarr/quality-size"],
"naming": ["docs/json/sonarr/naming"] "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