feat: Naming Sync

Fixes #179
spectre-console-remove-di-hacks
Robert Dailey 8 months ago
parent bc485a8ac2
commit 13b8e5679e

@ -13,6 +13,10 @@ changes you may need to make.
[breaking6]: https://recyclarr.dev/wiki/upgrade-guide/v6.0/
### Added
- Added Naming Sync (Media Management) for Sonarr v3, Sonarr v4, and Radarr (#179).
### Changed
- **BREAKING**: Minimum required Sonarr version increased to `3.0.9.1549` (Previous minimum version

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

@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/config/media-naming-radarr.json",
"type": "object",
"additionalProperties": false,
"properties": {
"folder": { "type": "string" },
"movie": {
"type": "object",
"additionalProperties": false,
"properties": {
"rename": { "type": "boolean" },
"format": { "type": "string" }
}
}
}
}

@ -0,0 +1,20 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "https://raw.githubusercontent.com/recyclarr/recyclarr/master/schemas/config/media-naming-sonarr.json",
"type": "object",
"additionalProperties": false,
"properties": {
"season": { "type": "string" },
"series": { "type": "string" },
"episodes": {
"type": "object",
"additionalProperties": false,
"properties": {
"rename": { "type": "boolean" },
"standard": { "type": "string" },
"daily": { "type": "string" },
"anime": { "type": "string" }
}
}
}
}

@ -10,6 +10,7 @@ using Recyclarr.Cli.Logging;
using Recyclarr.Cli.Migration;
using Recyclarr.Cli.Pipelines;
using Recyclarr.Cli.Pipelines.CustomFormat;
using Recyclarr.Cli.Pipelines.MediaNaming;
using Recyclarr.Cli.Pipelines.QualityProfile;
using Recyclarr.Cli.Pipelines.QualitySize;
using Recyclarr.Cli.Pipelines.ReleaseProfile;
@ -74,13 +75,15 @@ public static class CompositionRoot
builder.RegisterModule<QualityProfileAutofacModule>();
builder.RegisterModule<QualitySizeAutofacModule>();
builder.RegisterModule<ReleaseProfileAutofacModule>();
builder.RegisterModule<MediaNamingAutofacModule>();
builder.RegisterTypes(
typeof(TagSyncPipeline),
typeof(CustomFormatSyncPipeline),
typeof(QualityProfileSyncPipeline),
typeof(QualitySizeSyncPipeline),
typeof(ReleaseProfileSyncPipeline))
typeof(ReleaseProfileSyncPipeline),
typeof(MediaNamingSyncPipeline))
.As<ISyncPipeline>()
.OrderByRegistration();
}

@ -0,0 +1,21 @@
using Autofac;
using Autofac.Extras.AggregateService;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
namespace Recyclarr.Cli.Pipelines.MediaNaming;
public class MediaNamingAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterAggregateService<IMediaNamingPipelinePhases>();
builder.RegisterType<MediaNamingConfigPhase>();
builder.RegisterType<MediaNamingApiFetchPhase>();
builder.RegisterType<MediaNamingTransactionPhase>();
builder.RegisterType<MediaNamingPreviewPhase>();
builder.RegisterType<MediaNamingApiPersistencePhase>();
builder.RegisterType<MediaNamingPhaseLogger>();
}
}

@ -0,0 +1,47 @@
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.Config.Models;
namespace Recyclarr.Cli.Pipelines.MediaNaming;
public interface IMediaNamingPipelinePhases
{
MediaNamingConfigPhase ConfigPhase { get; }
MediaNamingPhaseLogger Logger { get; }
MediaNamingApiFetchPhase ApiFetchPhase { get; }
MediaNamingTransactionPhase TransactionPhase { get; }
MediaNamingPreviewPhase PreviewPhase { get; }
MediaNamingApiPersistencePhase ApiPersistencePhase { get; }
}
public class MediaNamingSyncPipeline : ISyncPipeline
{
private readonly IMediaNamingPipelinePhases _phases;
public MediaNamingSyncPipeline(IMediaNamingPipelinePhases phases)
{
_phases = phases;
}
public async Task Execute(ISyncSettings settings, IServiceConfiguration config)
{
var processedNaming = await _phases.ConfigPhase.Execute(config);
if (_phases.Logger.LogConfigPhaseAndExitIfNeeded(processedNaming))
{
return;
}
var serviceData = await _phases.ApiFetchPhase.Execute(config);
var transactions = _phases.TransactionPhase.Execute(serviceData, processedNaming);
if (settings.Preview)
{
_phases.PreviewPhase.Execute(transactions);
return;
}
await _phases.ApiPersistencePhase.Execute(config, transactions);
_phases.Logger.LogPersistenceResults(serviceData, transactions);
}
}

@ -0,0 +1,19 @@
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiFetchPhase
{
private readonly IMediaNamingApiService _api;
public MediaNamingApiFetchPhase(IMediaNamingApiService api)
{
_api = api;
}
public async Task<MediaNamingDto> Execute(IServiceConfiguration config)
{
return await _api.GetNaming(config);
}
}

@ -0,0 +1,19 @@
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingApiPersistencePhase
{
private readonly IMediaNamingApiService _api;
public MediaNamingApiPersistencePhase(IMediaNamingApiService api)
{
_api = api;
}
public async Task Execute(IServiceConfiguration config, MediaNamingDto serviceDto)
{
await _api.UpdateNaming(config, serviceDto);
}
}

@ -0,0 +1,126 @@
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.TrashGuide.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public record InvalidNamingConfig(string Type, string ConfigValue);
public record ProcessedNamingConfig
{
public required MediaNamingDto Dto { get; init; }
public IReadOnlyCollection<InvalidNamingConfig> InvalidNaming { get; init; } = new List<InvalidNamingConfig>();
}
public class MediaNamingConfigPhase
{
private readonly IMediaNamingGuideService _guide;
private readonly ISonarrCapabilityFetcher _sonarrCapabilities;
private List<InvalidNamingConfig> _errors = new();
public MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities)
{
_guide = guide;
_sonarrCapabilities = sonarrCapabilities;
}
public async Task<ProcessedNamingConfig> Execute(IServiceConfiguration config)
{
_errors = new List<InvalidNamingConfig>();
var dto = config switch
{
RadarrConfiguration c => ProcessRadarrNaming(c),
SonarrConfiguration c => await ProcessSonarrNaming(c),
_ => throw new ArgumentException("Configuration type unsupported for naming sync")
};
return new ProcessedNamingConfig {Dto = dto, InvalidNaming = _errors};
}
private MediaNamingDto ProcessRadarrNaming(RadarrConfiguration config)
{
var guideData = _guide.GetRadarrNamingData();
var configData = config.MediaNaming;
return new RadarrMediaNamingDto
{
StandardMovieFormat = ObtainFormat(guideData.File, configData.Movie?.Format, "Movie File Format"),
MovieFolderFormat = ObtainFormat(guideData.Folder, configData.Folder, "Movie Folder Format"),
RenameMovies = configData.Movie?.Rename
};
}
private async Task<MediaNamingDto> ProcessSonarrNaming(SonarrConfiguration config)
{
var guideData = _guide.GetSonarrNamingData();
var configData = config.MediaNaming;
var capabilities = await _sonarrCapabilities.GetCapabilities(config);
var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3";
return new SonarrMediaNamingDto
{
SeasonFolderFormat = ObtainFormat(guideData.Season, configData.Season, "Season Folder Format"),
SeriesFolderFormat = ObtainFormat(guideData.Series, configData.Series, "Series Folder Format"),
StandardEpisodeFormat = ObtainFormat(
guideData.Episodes.Standard,
configData.Episodes?.Standard,
keySuffix,
"Standard Episode Format"),
DailyEpisodeFormat = ObtainFormat(
guideData.Episodes.Daily,
configData.Episodes?.Daily,
keySuffix,
"Daily Episode Format"),
AnimeEpisodeFormat = ObtainFormat(
guideData.Episodes.Anime,
configData.Episodes?.Anime,
keySuffix,
"Anime Episode Format"),
RenameEpisodes = configData.Episodes?.Rename
};
}
private string? ObtainFormat(
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string errorDescription)
{
return ObtainFormat(guideFormats, configFormatKey, null, errorDescription);
}
private string? ObtainFormat(
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string? keySuffix,
string errorDescription)
{
if (configFormatKey is null)
{
return null;
}
// Use lower-case for the config value because System.Text.Json doesn't let us create a case-insensitive
// dictionary. The MediaNamingGuideService converts all parsed guide JSON keys to lower case.
var lowerKey = configFormatKey.ToLowerInvariant();
var keys = new List<string> {lowerKey};
if (keySuffix is not null)
{
// Put the more specific key first
keys.Insert(0, lowerKey + keySuffix);
}
foreach (var k in keys)
{
if (guideFormats.TryGetValue(k, out var format))
{
return format;
}
}
_errors.Add(new InvalidNamingConfig(errorDescription, configFormatKey));
return null;
}
}

@ -0,0 +1,62 @@
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPhaseLogger
{
private readonly ILogger _log;
public MediaNamingPhaseLogger(ILogger log)
{
_log = log;
}
// Returning 'true' means to exit. 'false' means to proceed.
public bool LogConfigPhaseAndExitIfNeeded(ProcessedNamingConfig config)
{
if (config.InvalidNaming.Any())
{
foreach (var (topic, invalidValue) in config.InvalidNaming)
{
_log.Error("An invalid media naming format is specified for {Topic}: {Value}", topic, invalidValue);
}
return true;
}
var differences = config.Dto switch
{
RadarrMediaNamingDto x => x.GetDifferences(new RadarrMediaNamingDto()),
SonarrMediaNamingDto x => x.GetDifferences(new SonarrMediaNamingDto()),
_ => throw new ArgumentException("Unsupported configuration type in LogConfigPhase method")
};
if (!differences.Any())
{
_log.Debug("No media naming changes to process");
return true;
}
return false;
}
public void LogPersistenceResults(MediaNamingDto oldDto, MediaNamingDto newDto)
{
var differences = oldDto switch
{
RadarrMediaNamingDto x => x.GetDifferences(newDto),
SonarrMediaNamingDto x => x.GetDifferences(newDto),
_ => throw new ArgumentException("Unsupported configuration type in LogPersistenceResults method")
};
if (differences.Any())
{
_log.Information("Media naming has been updated");
_log.Debug("Naming differences: {Diff}", differences);
}
else
{
_log.Information("Media naming is up to date!");
}
}
}

@ -0,0 +1,61 @@
using Recyclarr.ServarrApi.MediaNaming;
using Spectre.Console;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingPreviewPhase
{
private readonly IAnsiConsole _console;
private Table? _table;
public MediaNamingPreviewPhase(IAnsiConsole console)
{
_console = console;
}
public void Execute(MediaNamingDto serviceDto)
{
_table = new Table()
.Title("Media Naming [red](Preview)[/]")
.AddColumns("[b]Field[/]", "[b]Value[/]");
switch (serviceDto)
{
case RadarrMediaNamingDto dto:
PreviewRadarr(dto);
break;
case SonarrMediaNamingDto dto:
PreviewSonarr(dto);
break;
default:
throw new ArgumentException("Config type not supported in media naming preview");
}
_console.WriteLine();
_console.Write(_table);
}
private void AddRow(string field, object? value)
{
_table?.AddRow(field, value?.ToString() ?? "UNSET");
}
private void PreviewRadarr(RadarrMediaNamingDto dto)
{
AddRow("Enable Movie Renames?", dto.RenameMovies);
AddRow("Movie", dto.StandardMovieFormat);
AddRow("Folder", dto.MovieFolderFormat);
}
private void PreviewSonarr(SonarrMediaNamingDto dto)
{
AddRow("Enable Episode Renames?", dto.RenameEpisodes);
AddRow("Series Folder", dto.SeriesFolderFormat);
AddRow("Season Folder", dto.SeasonFolderFormat);
AddRow("Standard Episodes", dto.StandardEpisodeFormat);
AddRow("Daily Episodes", dto.DailyEpisodeFormat);
AddRow("Anime Episodes", dto.AnimeEpisodeFormat);
}
}

@ -0,0 +1,55 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
public class MediaNamingTransactionPhase
{
[SuppressMessage("Performance", "CA1822:Mark members as static")]
public MediaNamingDto Execute(MediaNamingDto serviceData, ProcessedNamingConfig config)
{
return serviceData switch
{
RadarrMediaNamingDto dto => UpdateRadarrDto(dto, config),
SonarrMediaNamingDto dto => UpdateSonarrDto(dto, config),
_ => throw new ArgumentException("Config type not supported in media naming transation phase")
};
}
private static RadarrMediaNamingDto UpdateRadarrDto(RadarrMediaNamingDto serviceDto, ProcessedNamingConfig config)
{
var configDto = (RadarrMediaNamingDto) config.Dto;
var combinedDto = serviceDto with
{
RenameMovies = configDto.RenameMovies,
MovieFolderFormat = configDto.MovieFolderFormat
};
if (configDto.RenameMovies is true)
{
combinedDto.StandardMovieFormat = configDto.StandardMovieFormat;
}
return combinedDto;
}
private static SonarrMediaNamingDto UpdateSonarrDto(SonarrMediaNamingDto serviceDto, ProcessedNamingConfig config)
{
var configDto = (SonarrMediaNamingDto) config.Dto;
var combinedDto = serviceDto with
{
RenameEpisodes = configDto.RenameEpisodes,
SeriesFolderFormat = configDto.SeriesFolderFormat,
SeasonFolderFormat = configDto.SeasonFolderFormat
};
if (configDto.RenameEpisodes is true)
{
combinedDto.StandardEpisodeFormat = configDto.StandardEpisodeFormat;
combinedDto.DailyEpisodeFormat = configDto.DailyEpisodeFormat;
combinedDto.AnimeEpisodeFormat = configDto.AnimeEpisodeFormat;
}
return combinedDto;
}
}

@ -2,7 +2,21 @@ using Recyclarr.Common;
namespace Recyclarr.Config.Models;
public record RadarrMovieNamingConfig
{
public bool? Rename { get; init; }
public string? Format { get; init; }
}
public record RadarrMediaNamingConfig
{
public string? Folder { get; init; }
public RadarrMovieNamingConfig? Movie { get; init; }
}
public record RadarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Radarr;
public RadarrMediaNamingConfig MediaNaming { get; init; } = new();
}

@ -1,4 +1,3 @@
using JetBrains.Annotations;
using Recyclarr.Common;
namespace Recyclarr.Config.Models;
@ -7,20 +6,37 @@ public record SonarrConfiguration : ServiceConfiguration
{
public override SupportedServices ServiceType => SupportedServices.Sonarr;
public IList<ReleaseProfileConfig> ReleaseProfiles { get; [UsedImplicitly] init; } =
public IList<ReleaseProfileConfig> ReleaseProfiles { get; init; } =
Array.Empty<ReleaseProfileConfig>();
public SonarrMediaNamingConfig MediaNaming { get; init; } = new();
}
public class ReleaseProfileConfig
{
public IReadOnlyCollection<string> TrashIds { get; [UsedImplicitly] init; } = Array.Empty<string>();
public bool StrictNegativeScores { get; [UsedImplicitly] init; }
public IReadOnlyCollection<string> Tags { get; [UsedImplicitly] init; } = Array.Empty<string>();
public SonarrProfileFilterConfig? Filter { get; [UsedImplicitly] init; }
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; [UsedImplicitly] init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Exclude { get; [UsedImplicitly] init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Include { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Exclude { get; init; } = Array.Empty<string>();
}
public record SonarrMediaNamingConfig
{
public string? Season { get; init; }
public string? Series { get; init; }
public SonarrEpisodeNamingConfig? Episodes { get; init; }
}
public record SonarrEpisodeNamingConfig
{
public bool? Rename { get; init; }
public string? Standard { get; init; }
public string? Daily { get; init; }
public string? Anime { get; init; }
}

@ -0,0 +1,23 @@
using JetBrains.Annotations;
namespace Recyclarr.Config.Parsing;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record RadarrMovieNamingConfigYaml
{
public bool? Rename { get; init; }
public string? Format { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record RadarrMediaNamingConfigYaml
{
public string? Folder { get; init; }
public RadarrMovieNamingConfigYaml? Movie { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record RadarrConfigYaml : ServiceConfigYaml
{
public RadarrMediaNamingConfigYaml? MediaNaming { get; init; }
}

@ -0,0 +1,43 @@
using JetBrains.Annotations;
namespace Recyclarr.Config.Parsing;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileFilterConfigYaml
{
public IReadOnlyCollection<string>? Include { get; init; }
public IReadOnlyCollection<string>? Exclude { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record ReleaseProfileConfigYaml
{
public IReadOnlyCollection<string>? TrashIds { get; init; }
public bool StrictNegativeScores { get; init; }
public IReadOnlyCollection<string>? Tags { get; init; }
public ReleaseProfileFilterConfigYaml? Filter { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrEpisodeNamingConfigYaml
{
public bool? Rename { get; init; }
public string? Standard { get; init; }
public string? Daily { get; init; }
public string? Anime { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrMediaNamingConfigYaml
{
public string? Season { get; init; }
public string? Series { get; init; }
public SonarrEpisodeNamingConfigYaml? Episodes { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrConfigYaml : ServiceConfigYaml
{
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; init; }
public SonarrMediaNamingConfigYaml? MediaNaming { get; init; }
}

@ -79,33 +79,6 @@ public record ServiceConfigYaml
public IReadOnlyCollection<IYamlInclude>? Include { get; init; }
}
[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; }
}
// This is usually empty (or the same as ServiceConfigYaml) on purpose.
// If empty, it is kept around to make it clear that this one is dedicated to Radarr.
[SuppressMessage("Minor Code Smell", "S2094:Classes should not be empty")]
public record RadarrConfigYaml : ServiceConfigYaml;
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record SonarrConfigYaml : ServiceConfigYaml
{
public IReadOnlyCollection<ReleaseProfileConfigYaml>? ReleaseProfiles { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]
public record RootConfigYaml
{

@ -14,11 +14,17 @@ public class ConfigYamlMapperProfile : Profile
CreateMap<QualitySizeConfigYaml, QualityDefinitionConfig>();
CreateMap<ReleaseProfileConfigYaml, ReleaseProfileConfig>();
CreateMap<ReleaseProfileFilterConfigYaml, SonarrProfileFilterConfig>();
CreateMap<ResetUnmatchedScoresConfigYaml, ResetUnmatchedScoresConfig>();
CreateMap<RadarrMediaNamingConfigYaml, RadarrMediaNamingConfig>();
CreateMap<RadarrMovieNamingConfigYaml, RadarrMovieNamingConfig>();
CreateMap<SonarrMediaNamingConfigYaml, SonarrMediaNamingConfig>();
CreateMap<SonarrEpisodeNamingConfigYaml, SonarrEpisodeNamingConfig>();
CreateMap<QualityProfileQualityConfigYaml, QualityProfileQualityConfig>()
.ForMember(x => x.Enabled, o => o.NullSubstitute(true));
CreateMap<ResetUnmatchedScoresConfigYaml, ResetUnmatchedScoresConfig>();
CreateMap<QualityProfileConfigYaml, QualityProfileConfig>()
.ForMember(x => x.UpgradeAllowed, o => o.MapFrom(x => x.Upgrade!.Allowed))
.ForMember(x => x.UpgradeUntilQuality, o => o.MapFrom(x => x.Upgrade!.UntilQuality))
@ -30,9 +36,11 @@ public class ConfigYamlMapperProfile : Profile
.ForMember(x => x.InstanceName, o => o.Ignore());
CreateMap<RadarrConfigYaml, RadarrConfiguration>()
.IncludeBase<ServiceConfigYaml, ServiceConfiguration>();
.IncludeBase<ServiceConfigYaml, ServiceConfiguration>()
.ForMember(x => x.MediaNaming, o => o.UseDestinationValue());
CreateMap<SonarrConfigYaml, SonarrConfiguration>()
.IncludeBase<ServiceConfigYaml, ServiceConfiguration>();
.IncludeBase<ServiceConfigYaml, ServiceConfiguration>()
.ForMember(x => x.MediaNaming, o => o.UseDestinationValue());
}
}

@ -1,5 +1,30 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
public class RadarrConfigMerger : ServiceConfigMerger<RadarrConfigYaml>
{
public override RadarrConfigYaml Merge(RadarrConfigYaml a, RadarrConfigYaml b)
{
return base.Merge(a, b) with
{
MediaNaming = Combine(a.MediaNaming, b.MediaNaming, MergeMediaNaming)
};
}
[SuppressMessage("ReSharper", "WithExpressionModifiesAllMembers")]
private static RadarrMediaNamingConfigYaml MergeMediaNaming(
RadarrMediaNamingConfigYaml a,
RadarrMediaNamingConfigYaml b)
{
return a with
{
Folder = b.Folder ?? a.Folder,
Movie = Combine(a.Movie, b.Movie, (a1, b1) => a1 with
{
Rename = b1.Rename ?? a1.Rename,
Format = b1.Format ?? a1.Format
})
};
}
}

@ -1,3 +1,5 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
public class SonarrConfigMerger : ServiceConfigMerger<SonarrConfigYaml>
@ -7,7 +9,28 @@ public class SonarrConfigMerger : ServiceConfigMerger<SonarrConfigYaml>
return base.Merge(a, b) with
{
ReleaseProfiles = Combine(a.ReleaseProfiles, b.ReleaseProfiles,
(x, y) => x.Concat(y).ToList())
(x, y) => x.Concat(y).ToList()),
MediaNaming = Combine(a.MediaNaming, b.MediaNaming, MergeMediaNaming)
};
}
[SuppressMessage("ReSharper", "WithExpressionModifiesAllMembers")]
private static SonarrMediaNamingConfigYaml MergeMediaNaming(
SonarrMediaNamingConfigYaml a,
SonarrMediaNamingConfigYaml b)
{
return a with
{
Series = b.Series ?? a.Series,
Season = b.Season ?? a.Season,
Episodes = Combine(a.Episodes, b.Episodes, (a1, b1) => a1 with
{
Rename = b1.Rename ?? a1.Rename,
Standard = b1.Standard ?? a1.Standard,
Daily = b1.Daily ?? a1.Daily,
Anime = b1.Anime ?? a1.Anime
})
};
}
}

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

@ -2,6 +2,7 @@ using Autofac;
using Flurl.Http.Configuration;
using Recyclarr.ServarrApi.CustomFormat;
using Recyclarr.ServarrApi.Http;
using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.ServarrApi.QualityDefinition;
using Recyclarr.ServarrApi.QualityProfile;
using Recyclarr.ServarrApi.System;
@ -13,11 +14,13 @@ public class ApiServicesAutofacModule : Module
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<SystemApiService>().As<ISystemApiService>();
builder.RegisterType<FlurlClientFactory>().As<IFlurlClientFactory>().SingleInstance();
builder.RegisterType<SystemApiService>().As<ISystemApiService>();
builder.RegisterType<ServiceRequestBuilder>().As<IServiceRequestBuilder>();
builder.RegisterType<QualityProfileApiService>().As<IQualityProfileApiService>();
builder.RegisterType<CustomFormatApiService>().As<ICustomFormatApiService>();
builder.RegisterType<QualityDefinitionApiService>().As<IQualityDefinitionApiService>();
builder.RegisterType<MediaNamingApiService>().As<IMediaNamingApiService>();
}
}

@ -0,0 +1,9 @@
using Recyclarr.Config.Models;
namespace Recyclarr.ServarrApi.MediaNaming;
public interface IMediaNamingApiService
{
Task<MediaNamingDto> GetNaming(IServiceConfiguration config);
Task UpdateNaming(IServiceConfiguration config, MediaNamingDto dto);
}

@ -0,0 +1,35 @@
using Flurl.Http;
using Recyclarr.Common;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.Http;
namespace Recyclarr.ServarrApi.MediaNaming;
public class MediaNamingApiService : IMediaNamingApiService
{
private readonly IServiceRequestBuilder _service;
public MediaNamingApiService(IServiceRequestBuilder service)
{
_service = service;
}
public async Task<MediaNamingDto> GetNaming(IServiceConfiguration config)
{
var response = await _service.Request(config, "config", "naming")
.GetAsync();
return config.ServiceType switch
{
SupportedServices.Radarr => await response.GetJsonAsync<RadarrMediaNamingDto>(),
SupportedServices.Sonarr => await response.GetJsonAsync<SonarrMediaNamingDto>(),
_ => throw new ArgumentException("Configuration type unsupported in GetNaming() API")
};
}
public async Task UpdateNaming(IServiceConfiguration config, MediaNamingDto dto)
{
await _service.Request(config, "config", "naming")
.PutJsonAsync(dto);
}
}

@ -0,0 +1,6 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.ServarrApi.MediaNaming;
[SuppressMessage("SonarLint", "S2094")]
public abstract record MediaNamingDto;

@ -0,0 +1,73 @@
namespace Recyclarr.ServarrApi.MediaNaming;
public static class MediaNamingDtoExtensions
{
public static IReadOnlyCollection<string> GetDifferences(this RadarrMediaNamingDto left, MediaNamingDto other)
{
var diff = new List<string>();
if (other is not RadarrMediaNamingDto right)
{
throw new ArgumentException("'other' is of the wrong type");
}
if (left.RenameMovies != right.RenameMovies)
{
diff.Add(nameof(left.RenameMovies));
}
if (left.MovieFolderFormat != right.MovieFolderFormat)
{
diff.Add(nameof(left.MovieFolderFormat));
}
if (left.StandardMovieFormat != right.StandardMovieFormat)
{
diff.Add(nameof(left.StandardMovieFormat));
}
return diff;
}
public static IReadOnlyCollection<string> GetDifferences(this SonarrMediaNamingDto left, MediaNamingDto other)
{
var diff = new List<string>();
if (other is not SonarrMediaNamingDto right)
{
throw new ArgumentException("'other' is of the wrong type");
}
if (left.RenameEpisodes != right.RenameEpisodes)
{
diff.Add(nameof(left.RenameEpisodes));
}
if (left.SeasonFolderFormat != right.SeasonFolderFormat)
{
diff.Add(nameof(left.SeasonFolderFormat));
}
if (left.SeriesFolderFormat != right.SeriesFolderFormat)
{
diff.Add(nameof(left.SeriesFolderFormat));
}
if (left.StandardEpisodeFormat != right.StandardEpisodeFormat)
{
diff.Add(nameof(left.StandardEpisodeFormat));
}
if (left.DailyEpisodeFormat != right.DailyEpisodeFormat)
{
diff.Add(nameof(left.DailyEpisodeFormat));
}
if (left.AnimeEpisodeFormat != right.AnimeEpisodeFormat)
{
diff.Add(nameof(left.AnimeEpisodeFormat));
}
return diff;
}
}

@ -0,0 +1,32 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.ServarrApi.MediaNaming;
public record RadarrMediaNamingDto : MediaNamingDto
{
private string? _movieFormat;
private readonly string? _folderFormat;
private readonly bool? _renameMovies;
public string? StandardMovieFormat
{
get => _movieFormat;
set => DtoUtil.SetIfNotNull(ref _movieFormat, value);
}
public string? MovieFolderFormat
{
get => _folderFormat;
init => DtoUtil.SetIfNotNull(ref _folderFormat, value);
}
public bool? RenameMovies
{
get => _renameMovies;
init => DtoUtil.SetIfNotNull(ref _renameMovies, value);
}
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}

@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.ServarrApi.MediaNaming;
public record SonarrMediaNamingDto : MediaNamingDto
{
private readonly string? _seriesFolderFormat;
private readonly string? _seasonFolderFormat;
private string? _standardEpisodeFormat;
private string? _dailyEpisodeFormat;
private string? _animeEpisodeFormat;
private readonly bool? _renameEpisodes;
public string? SeriesFolderFormat
{
get => _seriesFolderFormat;
init => DtoUtil.SetIfNotNull(ref _seriesFolderFormat, value);
}
public string? SeasonFolderFormat
{
get => _seasonFolderFormat;
init => DtoUtil.SetIfNotNull(ref _seasonFolderFormat, value);
}
public string? StandardEpisodeFormat
{
get => _standardEpisodeFormat;
set => DtoUtil.SetIfNotNull(ref _standardEpisodeFormat, value);
}
public string? DailyEpisodeFormat
{
get => _dailyEpisodeFormat;
set => DtoUtil.SetIfNotNull(ref _dailyEpisodeFormat, value);
}
public string? AnimeEpisodeFormat
{
get => _animeEpisodeFormat;
set => DtoUtil.SetIfNotNull(ref _animeEpisodeFormat, value);
}
public bool? RenameEpisodes
{
get => _renameEpisodes;
init => DtoUtil.SetIfNotNull(ref _renameEpisodes, value);
}
[UsedImplicitly, JsonExtensionData]
public Dictionary<string, object> ExtraJson { get; init; } = new();
}

@ -1,5 +1,6 @@
using Autofac;
using Recyclarr.TrashGuide.CustomFormat;
using Recyclarr.TrashGuide.MediaNaming;
using Recyclarr.TrashGuide.QualitySize;
using Recyclarr.TrashGuide.ReleaseProfile;
@ -25,5 +26,8 @@ public class GuideAutofacModule : Module
// Quality Size
builder.RegisterType<QualitySizeGuideService>().As<IQualitySizeGuideService>().SingleInstance();
builder.RegisterType<QualitySizeGuideParser>();
// Media Naming
builder.RegisterType<MediaNamingGuideService>().As<IMediaNamingGuideService>();
}
}

@ -0,0 +1,7 @@
namespace Recyclarr.TrashGuide.MediaNaming;
public interface IMediaNamingGuideService
{
RadarrMediaNamingData GetRadarrNamingData();
SonarrMediaNamingData GetSonarrNamingData();
}

@ -0,0 +1,65 @@
using System.IO.Abstractions;
using Recyclarr.Common;
using Recyclarr.Json.Loading;
using Recyclarr.Repo;
namespace Recyclarr.TrashGuide.MediaNaming;
public class MediaNamingGuideService : IMediaNamingGuideService
{
private readonly IRepoMetadataBuilder _metadataBuilder;
private readonly GuideJsonLoader _jsonLoader;
public MediaNamingGuideService(IRepoMetadataBuilder metadataBuilder, GuideJsonLoader jsonLoader)
{
_metadataBuilder = metadataBuilder;
_jsonLoader = jsonLoader;
}
private IReadOnlyList<IDirectoryInfo> CreatePaths(SupportedServices serviceType)
{
var metadata = _metadataBuilder.GetMetadata();
return serviceType switch
{
SupportedServices.Radarr => _metadataBuilder.ToDirectoryInfoList(metadata.JsonPaths.Radarr.Naming),
SupportedServices.Sonarr => _metadataBuilder.ToDirectoryInfoList(metadata.JsonPaths.Sonarr.Naming),
_ => throw new ArgumentOutOfRangeException(nameof(serviceType), serviceType, null)
};
}
private static IReadOnlyDictionary<string, string> JoinDictionaries(
IEnumerable<IReadOnlyDictionary<string, string>> dictionaries)
{
return dictionaries
.SelectMany(x => x.Select(y => (y.Key, y.Value)))
.ToDictionary(x => x.Key.ToLowerInvariant(), x => x.Value);
}
public RadarrMediaNamingData GetRadarrNamingData()
{
var paths = CreatePaths(SupportedServices.Radarr);
var data = _jsonLoader.LoadAllFilesAtPaths<RadarrMediaNamingData>(paths);
return new RadarrMediaNamingData
{
File = JoinDictionaries(data.Select(x => x.File)),
Folder = JoinDictionaries(data.Select(x => x.Folder))
};
}
public SonarrMediaNamingData GetSonarrNamingData()
{
var paths = CreatePaths(SupportedServices.Sonarr);
var data = _jsonLoader.LoadAllFilesAtPaths<SonarrMediaNamingData>(paths);
return new SonarrMediaNamingData
{
Season = JoinDictionaries(data.Select(x => x.Season)),
Series = JoinDictionaries(data.Select(x => x.Series)),
Episodes = new SonarrEpisodeNamingData
{
Anime = JoinDictionaries(data.Select(x => x.Episodes.Anime)),
Daily = JoinDictionaries(data.Select(x => x.Episodes.Daily)),
Standard = JoinDictionaries(data.Select(x => x.Episodes.Standard))
}
};
}
}

@ -0,0 +1,7 @@
namespace Recyclarr.TrashGuide.MediaNaming;
public record RadarrMediaNamingData
{
public IReadOnlyDictionary<string, string> Folder { get; init; } = new Dictionary<string, string>();
public IReadOnlyDictionary<string, string> File { get; init; } = new Dictionary<string, string>();
}

@ -0,0 +1,15 @@
namespace Recyclarr.TrashGuide.MediaNaming;
public record SonarrEpisodeNamingData
{
public IReadOnlyDictionary<string, string> Standard { get; init; } = new Dictionary<string, string>();
public IReadOnlyDictionary<string, string> Daily { get; init; } = new Dictionary<string, string>();
public IReadOnlyDictionary<string, string> Anime { get; init; } = new Dictionary<string, string>();
}
public record SonarrMediaNamingData
{
public IReadOnlyDictionary<string, string> Season { get; init; } = new Dictionary<string, string>();
public IReadOnlyDictionary<string, string> Series { get; init; } = new Dictionary<string, string>();
public SonarrEpisodeNamingData Episodes { get; init; } = new();
}

@ -0,0 +1,282 @@
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.Common;
using Recyclarr.Compatibility.Sonarr;
using Recyclarr.Config.Models;
using Recyclarr.ServarrApi.MediaNaming;
using Recyclarr.TrashGuide.MediaNaming;
namespace Recyclarr.Cli.Tests.Pipelines.MediaNaming;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MediaNamingConfigPhaseTest
{
private static readonly SonarrMediaNamingData SonarrNamingData = new()
{
Season = new Dictionary<string, string>
{
{"default", "season_default"}
},
Series = new Dictionary<string, string>
{
{"default", "series_default"},
{"plex", "series_plex"},
{"emby", "series_emby"}
},
Episodes = new SonarrEpisodeNamingData
{
Standard = new Dictionary<string, string>
{
{"default:3", "episodes_standard_default_3"},
{"default:4", "episodes_standard_default_4"},
{"original", "episodes_standard_original"}
},
Daily = new Dictionary<string, string>
{
{"default:3", "episodes_daily_default_3"},
{"default:4", "episodes_daily_default_4"},
{"original", "episodes_daily_original"}
},
Anime = new Dictionary<string, string>
{
{"default:3", "episodes_anime_default_3"},
{"default:4", "episodes_anime_default_4"}
}
}
};
private static readonly RadarrMediaNamingData RadarrNamingData = new()
{
Folder = new Dictionary<string, string>
{
{"default", "folder_default"},
{"plex", "folder_plex"},
{"emby", "folder_emby"}
},
File = new Dictionary<string, string>
{
{"default", "file_default"},
{"emby", "file_emby"},
{"jellyfin", "file_jellyfin"}
}
};
[Test, AutoMockData]
public async Task Sonarr_v3_naming(
[Frozen] ISonarrCapabilityFetcher capabilities,
[Frozen] IMediaNamingGuideService guide,
MediaNamingConfigPhase sut)
{
capabilities.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = false
});
guide.GetSonarrNamingData().Returns(SonarrNamingData);
var config = new SonarrConfiguration
{
InstanceName = "sonarr",
MediaNaming = new SonarrMediaNamingConfig
{
Season = "default",
Series = "plex",
Episodes = new SonarrEpisodeNamingConfig
{
Rename = true,
Standard = "default",
Daily = "default",
Anime = "default"
}
}
};
var result = await sut.Execute(config);
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default_3",
DailyEpisodeFormat = "episodes_daily_default_3",
AnimeEpisodeFormat = "episodes_anime_default_3"
}
},
o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public async Task Sonarr_v4_naming(
[Frozen] ISonarrCapabilityFetcher capabilities,
[Frozen] IMediaNamingGuideService guide,
MediaNamingConfigPhase sut)
{
capabilities.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = true
});
guide.GetSonarrNamingData().Returns(SonarrNamingData);
var config = new SonarrConfiguration
{
InstanceName = "sonarr",
MediaNaming = new SonarrMediaNamingConfig
{
Season = "default",
Series = "plex",
Episodes = new SonarrEpisodeNamingConfig
{
Rename = true,
Standard = "default",
Daily = "default",
Anime = "default"
}
}
};
var result = await sut.Execute(config);
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default_4",
DailyEpisodeFormat = "episodes_daily_default_4",
AnimeEpisodeFormat = "episodes_anime_default_4"
}
},
o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public async Task Sonarr_invalid_names(
[Frozen] ISonarrCapabilityFetcher capabilities,
[Frozen] IMediaNamingGuideService guide,
MediaNamingConfigPhase sut)
{
capabilities.GetCapabilities(default!).ReturnsForAnyArgs(new SonarrCapabilities
{
SupportsCustomFormats = true
});
guide.GetSonarrNamingData().Returns(SonarrNamingData);
var config = new SonarrConfiguration
{
InstanceName = "sonarr",
MediaNaming = new SonarrMediaNamingConfig
{
Season = "bad1",
Series = "bad2",
Episodes = new SonarrEpisodeNamingConfig
{
Rename = true,
Standard = "bad3",
Daily = "bad4",
Anime = "bad5"
}
}
};
var result = await sut.Execute(config);
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = true
},
InvalidNaming = new[]
{
new InvalidNamingConfig("Season Folder Format", "bad1"),
new InvalidNamingConfig("Series Folder Format", "bad2"),
new InvalidNamingConfig("Standard Episode Format", "bad3"),
new InvalidNamingConfig("Daily Episode Format", "bad4"),
new InvalidNamingConfig("Anime Episode Format", "bad5")
}
},
o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public async Task Radarr_naming(
[Frozen] IMediaNamingGuideService guide,
MediaNamingConfigPhase sut)
{
guide.GetRadarrNamingData().Returns(RadarrNamingData);
var config = new RadarrConfiguration
{
InstanceName = "radarr",
MediaNaming = new RadarrMediaNamingConfig
{
Folder = "plex",
Movie = new RadarrMovieNamingConfig
{
Rename = true,
Format = "emby"
}
}
};
var result = await sut.Execute(config);
result.Should().NotBeNull();
result.Should().BeEquivalentTo(new ProcessedNamingConfig
{
Dto = new RadarrMediaNamingDto
{
RenameMovies = true,
StandardMovieFormat = "file_emby",
MovieFolderFormat = "folder_plex"
}
},
o => o.RespectingRuntimeTypes());
}
private sealed record UnsupportedConfigType : ServiceConfiguration
{
public override SupportedServices ServiceType => default!;
}
[Test, AutoMockData]
public async Task Throw_on_unknown_config_type(
MediaNamingConfigPhase sut)
{
var act = () => sut.Execute(new UnsupportedConfigType {InstanceName = ""});
await act.Should().ThrowAsync<ArgumentException>();
}
[Test, AutoMockData]
public async Task Assign_null_when_config_null(
[Frozen] IMediaNamingGuideService guide,
MediaNamingConfigPhase sut)
{
guide.GetRadarrNamingData().Returns(RadarrNamingData);
var config = new RadarrConfiguration
{
InstanceName = "",
MediaNaming = new RadarrMediaNamingConfig
{
Folder = null
}
};
var result = await sut.Execute(config);
result.Should().NotBeNull();
result.Dto.Should().BeOfType<RadarrMediaNamingDto>()
.Which.MovieFolderFormat.Should().BeNull();
}
}

@ -0,0 +1,109 @@
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Tests.Pipelines.MediaNaming;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MediaNamingTransactionPhaseRadarrTest
{
[Test, AutoMockData]
public void Radarr_left_null(
MediaNamingTransactionPhase sut)
{
var left = new RadarrMediaNamingDto();
var right = new ProcessedNamingConfig
{
Dto = new RadarrMediaNamingDto
{
RenameMovies = true,
StandardMovieFormat = "file_format",
MovieFolderFormat = "folder_format"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(right.Dto, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Radarr_right_null(
MediaNamingTransactionPhase sut)
{
var left = new RadarrMediaNamingDto
{
RenameMovies = true,
StandardMovieFormat = "file_format",
MovieFolderFormat = "folder_format"
};
var right = new ProcessedNamingConfig
{
Dto = new RadarrMediaNamingDto()
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(left, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Radarr_right_and_left_with_rename(
MediaNamingTransactionPhase sut)
{
var left = new RadarrMediaNamingDto
{
RenameMovies = false,
StandardMovieFormat = "file_format",
MovieFolderFormat = "folder_format"
};
var right = new ProcessedNamingConfig
{
Dto = new RadarrMediaNamingDto
{
RenameMovies = true,
StandardMovieFormat = "file_format2",
MovieFolderFormat = "folder_format2"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(right.Dto, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Radarr_right_and_left_without_rename(
MediaNamingTransactionPhase sut)
{
var left = new RadarrMediaNamingDto
{
RenameMovies = true,
StandardMovieFormat = "file_format",
MovieFolderFormat = "folder_format"
};
var right = new ProcessedNamingConfig
{
Dto = new RadarrMediaNamingDto
{
RenameMovies = false,
StandardMovieFormat = "file_format2",
MovieFolderFormat = "folder_format2"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(new RadarrMediaNamingDto
{
RenameMovies = false,
StandardMovieFormat = "file_format",
MovieFolderFormat = "folder_format2"
},
o => o.RespectingRuntimeTypes());
}
}

@ -0,0 +1,130 @@
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;
using Recyclarr.ServarrApi.MediaNaming;
namespace Recyclarr.Cli.Tests.Pipelines.MediaNaming;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MediaNamingTransactionPhaseSonarrTest
{
[Test, AutoMockData]
public void Sonarr_left_null(
MediaNamingTransactionPhase sut)
{
var left = new SonarrMediaNamingDto();
var right = new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default_3",
DailyEpisodeFormat = "episodes_daily_default_3",
AnimeEpisodeFormat = "episodes_anime_default_3"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(right.Dto, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Sonarr_right_null(
MediaNamingTransactionPhase sut)
{
var left = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default_3",
DailyEpisodeFormat = "episodes_daily_default_3",
AnimeEpisodeFormat = "episodes_anime_default_3"
};
var right = new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto()
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(left, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Sonarr_right_and_left_with_rename(
MediaNamingTransactionPhase sut)
{
var left = new SonarrMediaNamingDto
{
RenameEpisodes = false,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default",
DailyEpisodeFormat = "episodes_daily_default",
AnimeEpisodeFormat = "episodes_anime_default"
};
var right = new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default2",
SeriesFolderFormat = "series_plex2",
StandardEpisodeFormat = "episodes_standard_default2",
DailyEpisodeFormat = "episodes_daily_default2",
AnimeEpisodeFormat = "episodes_anime_default2"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(right.Dto, o => o.RespectingRuntimeTypes());
}
[Test, AutoMockData]
public void Sonarr_right_and_left_without_rename(
MediaNamingTransactionPhase sut)
{
var left = new SonarrMediaNamingDto
{
RenameEpisodes = true,
SeasonFolderFormat = "season_default",
SeriesFolderFormat = "series_plex",
StandardEpisodeFormat = "episodes_standard_default",
DailyEpisodeFormat = "episodes_daily_default",
AnimeEpisodeFormat = "episodes_anime_default"
};
var right = new ProcessedNamingConfig
{
Dto = new SonarrMediaNamingDto
{
RenameEpisodes = false,
SeasonFolderFormat = "season_default2",
SeriesFolderFormat = "series_plex2",
StandardEpisodeFormat = "episodes_standard_default2",
DailyEpisodeFormat = "episodes_daily_default2",
AnimeEpisodeFormat = "episodes_anime_default2"
}
};
var result = sut.Execute(left, right);
result.Should().BeEquivalentTo(new SonarrMediaNamingDto
{
RenameEpisodes = false,
SeasonFolderFormat = "season_default2",
SeriesFolderFormat = "series_plex2",
StandardEpisodeFormat = "episodes_standard_default",
DailyEpisodeFormat = "episodes_daily_default",
AnimeEpisodeFormat = "episodes_anime_default"
},
o => o.RespectingRuntimeTypes());
}
}

@ -0,0 +1,8 @@
{
"folder": {
"plex": "folder_plex"
},
"file": {
"emby": "file_emby"
}
}

@ -0,0 +1,10 @@
{
"folder": {
"default": "folder_default",
"emby": "folder_emby"
},
"file": {
"default": "file_default",
"jellyfin": "file_jellyfin"
}
}

@ -0,0 +1,19 @@
{
"series": {
"default": "series_default"
},
"episodes": {
"standard": {
"default:3": "episodes_standard_default_3",
"original": "episodes_standard_original"
},
"daily": {
"default:4": "episodes_daily_default_4",
"original": "episodes_daily_original"
},
"anime": {
"default:3": "episodes_anime_default_3",
"default:4": "episodes_anime_default_4"
}
}
}

@ -0,0 +1,17 @@
{
"season": {
"default": "season_default"
},
"series": {
"plex": "series_plex",
"emby": "series_emby"
},
"episodes": {
"standard": {
"default:4": "episodes_standard_default_4"
},
"daily": {
"default:3": "episodes_daily_default_3"
}
}
}

@ -0,0 +1,110 @@
using System.IO.Abstractions;
using Recyclarr.Common.Extensions;
using Recyclarr.Repo;
using Recyclarr.TestLibrary;
using Recyclarr.TrashGuide.MediaNaming;
namespace Recyclarr.IntegrationTests.TrashGuide;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MediaNamingGuideServiceTest : IntegrationTestFixture
{
private void SetupMetadata()
{
var repo = Resolve<ITrashGuidesRepo>();
const string metadataJson =
"""
{
"json_paths": {
"radarr": {
"naming": ["radarr/naming1", "radarr/naming2"]
},
"sonarr": {
"naming": ["sonarr/naming1", "sonarr/naming2"]
}
}
}
""";
Fs.AddFile(repo.Path.File("metadata.json"), new MockFileData(metadataJson));
}
[Test]
public void Radarr_naming()
{
SetupMetadata();
var repo = Resolve<ITrashGuidesRepo>();
var jsonPath = repo.Path.SubDir("radarr");
Fs.AddSameFileFromEmbeddedResource(jsonPath.SubDir("naming1").File("radarr_naming1.json"), GetType());
Fs.AddSameFileFromEmbeddedResource(jsonPath.SubDir("naming2").File("radarr_naming2.json"), GetType());
var sut = Resolve<MediaNamingGuideService>();
var result = sut.GetRadarrNamingData();
result.Should().BeEquivalentTo(new RadarrMediaNamingData
{
Folder = new Dictionary<string, string>
{
{"default", "folder_default"},
{"plex", "folder_plex"},
{"emby", "folder_emby"}
},
File = new Dictionary<string, string>
{
{"default", "file_default"},
{"emby", "file_emby"},
{"jellyfin", "file_jellyfin"}
}
});
}
[Test]
public void Sonarr_naming()
{
SetupMetadata();
var repo = Resolve<ITrashGuidesRepo>();
var jsonPath = repo.Path.SubDir("sonarr");
Fs.AddSameFileFromEmbeddedResource(jsonPath.SubDir("naming1").File("sonarr_naming1.json"), GetType());
Fs.AddSameFileFromEmbeddedResource(jsonPath.SubDir("naming2").File("sonarr_naming2.json"), GetType());
var sut = Resolve<MediaNamingGuideService>();
var result = sut.GetSonarrNamingData();
result.Should().BeEquivalentTo(new SonarrMediaNamingData
{
Season = new Dictionary<string, string>
{
{"default", "season_default"}
},
Series = new Dictionary<string, string>
{
{"default", "series_default"},
{"plex", "series_plex"},
{"emby", "series_emby"}
},
Episodes = new SonarrEpisodeNamingData
{
Standard = new Dictionary<string, string>
{
{"default:3", "episodes_standard_default_3"},
{"default:4", "episodes_standard_default_4"},
{"original", "episodes_standard_original"}
},
Daily = new Dictionary<string, string>
{
{"default:3", "episodes_daily_default_3"},
{"default:4", "episodes_daily_default_4"},
{"original", "episodes_daily_original"}
},
Anime = new Dictionary<string, string>
{
{"default:3", "episodes_anime_default_3"},
{"default:4", "episodes_anime_default_4"}
}
}
});
}
}

@ -0,0 +1,65 @@
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeApiKeyTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ApiKey = "a"
};
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();
// API Key should not be merged!
var rightConfig = new SonarrConfigYaml
{
ApiKey = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ApiKey = "a"
};
// API Key should not be merged!
var rightConfig = new SonarrConfigYaml
{
ApiKey = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
}

@ -0,0 +1,65 @@
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeBaseUrlTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
BaseUrl = "a"
};
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();
// BaseUrl should not be merged!
var rightConfig = new SonarrConfigYaml
{
BaseUrl = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
BaseUrl = "a"
};
// Baseurl should not be merged!
var rightConfig = new SonarrConfigYaml
{
BaseUrl = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
}

@ -0,0 +1,190 @@
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeCustomFormatsTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
}
}
};
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
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
[Test]
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100},
new QualityScoreConfigYaml {Name = "d", Score = 101},
new QualityScoreConfigYaml {Name = "e", Score = 102}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "f", Score = 100}
}
}
}
};
var rightConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id3", "id4"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 200}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id5", "id6"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 300}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 50}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 101}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 102}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "f", Score = 100}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id3", "id4"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 200}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id5", "id6"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 300}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 50}
}
}
}
});
}
}

@ -0,0 +1,97 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeMediaNamingRadarrTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new RadarrConfigYaml
{
MediaNaming = new RadarrMediaNamingConfigYaml
{
Folder = "folder1",
Movie = new RadarrMovieNamingConfigYaml
{
Rename = false,
Format = "format1"
}
}
};
var rightConfig = new RadarrConfigYaml();
var sut = new RadarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Non_empty_right_to_empty_left()
{
var leftConfig = new RadarrConfigYaml();
var rightConfig = new RadarrConfigYaml
{
MediaNaming = new RadarrMediaNamingConfigYaml
{
Folder = "folder1",
Movie = new RadarrMovieNamingConfigYaml
{
Rename = false,
Format = "format1"
}
}
};
var sut = new RadarrConfigMerger();
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 RadarrConfigYaml
{
MediaNaming = new RadarrMediaNamingConfigYaml
{
Folder = "folder1",
Movie = new RadarrMovieNamingConfigYaml
{
Rename = false,
Format = "format1"
}
}
};
var rightConfig = new RadarrConfigYaml
{
MediaNaming = new RadarrMediaNamingConfigYaml
{
Folder = "folder2",
Movie = new RadarrMovieNamingConfigYaml
{
Rename = false,
Format = "format2"
}
}
};
var sut = new RadarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
}

@ -0,0 +1,109 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeMediaNamingSonarrTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
MediaNaming = new SonarrMediaNamingConfigYaml
{
Series = "series1",
Season = "season1",
Episodes = new SonarrEpisodeNamingConfigYaml
{
Rename = false,
Standard = "standard1",
Daily = "daily1",
Anime = "anime1"
}
}
};
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
{
MediaNaming = new SonarrMediaNamingConfigYaml
{
Series = "series1",
Season = "season1",
Episodes = new SonarrEpisodeNamingConfigYaml
{
Rename = false,
Standard = "standard1",
Daily = "daily1",
Anime = "anime1"
}
}
};
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
{
MediaNaming = new SonarrMediaNamingConfigYaml
{
Series = "series1",
Season = "season1",
Episodes = new SonarrEpisodeNamingConfigYaml
{
Rename = false,
Standard = "standard1",
Daily = "daily1",
Anime = "anime1"
}
}
};
var rightConfig = new SonarrConfigYaml
{
MediaNaming = new SonarrMediaNamingConfigYaml
{
Series = "series2",
Season = "season2",
Episodes = new SonarrEpisodeNamingConfigYaml
{
Rename = false,
Standard = "standard2",
Daily = "daily2",
Anime = "anime2"
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
}

@ -0,0 +1,79 @@
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeQualityDefinitionTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
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
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
[Test]
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
var rightConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type2",
PreferredRatio = 1.0m
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
}

@ -0,0 +1,221 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class MergeQualityProfilesTest
{
[Test]
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
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
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
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
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
var rightConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
ScoreSet = "set2",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Except = new[] {"except2", "except3"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
UntilQuality = "quality2"
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = false,
Name = "quality2",
Qualities = new[] {"quality3"}
},
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality4",
Qualities = new[] {"quality5", "quality6"}
}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set2",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1", "except2", "except3"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality2",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = false,
Name = "quality2",
Qualities = new[] {"quality3"}
},
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality4",
Qualities = new[] {"quality5", "quality6"}
}
}
}
}
});
}
}

@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using FluentAssertions.Execution;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
@ -7,10 +6,10 @@ namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class SonarrConfigMergerTest
public class MergeReleaseProfilesTest
{
[Test]
public void Merge_release_profiles_from_empty_right_to_non_empty_left()
public void Empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
@ -40,7 +39,7 @@ public class SonarrConfigMergerTest
}
[Test]
public void Merge_release_profiles_from_non_empty_right_to_empty_left()
public void Non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
@ -71,7 +70,7 @@ public class SonarrConfigMergerTest
[Test]
[SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")]
public void Merge_release_profiles_from_non_empty_right_to_non_empty_left()
public void Non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
@ -124,8 +123,6 @@ public class SonarrConfigMergerTest
var result = sut.Merge(leftConfig, rightConfig);
using var scope = new AssertionScope().UsingLineBreaks;
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
ReleaseProfiles = leftConfig.ReleaseProfiles.Concat(rightConfig.ReleaseProfiles).ToList()

@ -1,595 +0,0 @@
using System.Diagnostics.CodeAnalysis;
using FluentAssertions.Execution;
using Recyclarr.Config.Models;
using Recyclarr.Config.Parsing;
using Recyclarr.Config.Parsing.PostProcessing.ConfigMerging;
namespace Recyclarr.Tests.Config.Parsing.PostProcessing.ConfigMerging;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ServiceConfigMergerTest
{
[Test]
public void Merge_api_key_from_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ApiKey = "a"
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_api_key_from_non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
// API Key should not be merged!
var rightConfig = new SonarrConfigYaml
{
ApiKey = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_api_key_from_non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
ApiKey = "a"
};
// API Key should not be merged!
var rightConfig = new SonarrConfigYaml
{
ApiKey = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
//------------------------------------------------------
[Test]
public void Merge_base_url_from_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
BaseUrl = "a"
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_base_url_from_non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
// BaseUrl should not be merged!
var rightConfig = new SonarrConfigYaml
{
BaseUrl = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_base_url_from_non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
BaseUrl = "a"
};
// Baseurl should not be merged!
var rightConfig = new SonarrConfigYaml
{
BaseUrl = "b"
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
//------------------------------------------------------
[Test]
public void Merge_quality_definition_from_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_quality_definition_from_non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
var rightConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
[Test]
public void Merge_quality_definition_from_non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type1",
PreferredRatio = 0.5m
}
};
var rightConfig = new SonarrConfigYaml
{
QualityDefinition = new QualitySizeConfigYaml
{
Type = "type2",
PreferredRatio = 1.0m
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
//------------------------------------------------------
[Test]
public void Merge_custom_formats_from_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
}
}
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_custom_formats_from_non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
var rightConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(rightConfig);
}
[Test]
public void Merge_custom_formats_from_non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100},
new QualityScoreConfigYaml {Name = "d", Score = 101},
new QualityScoreConfigYaml {Name = "e", Score = 102}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "f", Score = 100}
}
}
}
};
var rightConfig = new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id3", "id4"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 200}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id5", "id6"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 300}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 50}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
CustomFormats = new[]
{
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 100}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 101}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1", "id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 102}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id2"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "f", Score = 100}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id3", "id4"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "d", Score = 200}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id5", "id6"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "e", Score = 300}
}
},
new CustomFormatConfigYaml
{
TrashIds = new[] {"id1"},
QualityProfiles = new[]
{
new QualityScoreConfigYaml {Name = "c", Score = 50}
}
}
}
});
}
//------------------------------------------------------
[Test]
public void Merge_quality_profiles_from_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
var rightConfig = new SonarrConfigYaml();
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
result.Should().BeEquivalentTo(leftConfig);
}
[Test]
public void Merge_quality_profiles_from_non_empty_right_to_empty_left()
{
var leftConfig = new SonarrConfigYaml();
var rightConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
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 Merge_quality_profiles_from_non_empty_right_to_non_empty_left()
{
var leftConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set1",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality1",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality1",
Qualities = new[] {"quality"}
}
}
}
}
};
var rightConfig = new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
ScoreSet = "set2",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Except = new[] {"except2", "except3"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
UntilQuality = "quality2"
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = false,
Name = "quality2",
Qualities = new[] {"quality3"}
},
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality4",
Qualities = new[] {"quality5", "quality6"}
}
}
}
}
};
var sut = new SonarrConfigMerger();
var result = sut.Merge(leftConfig, rightConfig);
using var scope = new AssertionScope().UsingLineBreaks;
result.Should().BeEquivalentTo(new SonarrConfigYaml
{
QualityProfiles = new[]
{
new QualityProfileConfigYaml
{
Name = "e",
QualitySort = QualitySortAlgorithm.Top,
MinFormatScore = 100,
ScoreSet = "set2",
ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml
{
Enabled = true,
Except = new[] {"except1", "except2", "except3"}
},
Upgrade = new QualityProfileFormatUpgradeYaml
{
Allowed = true,
UntilQuality = "quality2",
UntilScore = 200
},
Qualities = new[]
{
new QualityProfileQualityConfigYaml
{
Enabled = false,
Name = "quality2",
Qualities = new[] {"quality3"}
},
new QualityProfileQualityConfigYaml
{
Enabled = true,
Name = "quality4",
Qualities = new[] {"quality5", "quality6"}
}
}
}
}
});
}
}

@ -8,4 +8,7 @@
<EmbeddedResource Include="Common\DefaultDataFile.txt" />
<EmbeddedResource Include="Common\TestData\DataFile.txt" />
</ItemGroup>
<ItemGroup>
<Folder Include="TrashGuide\MediaNaming\" />
</ItemGroup>
</Project>

@ -0,0 +1,46 @@
using System.IO.Abstractions;
using Recyclarr.Repo;
namespace Recyclarr.Tests.Repo;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class TrashRepoMetadataBuilderTest
{
private const string MetadataJson =
"""
{
"$schema": "metadata.schema.json",
"json_paths": {
"radarr": {
"custom_formats": ["docs/json/radarr/cf"],
"qualities": ["docs/json/radarr/quality-size"],
"naming": ["docs/json/radarr/naming"]
},
"sonarr": {
"release_profiles": ["docs/json/sonarr/rp"],
"custom_formats": ["docs/json/sonarr/cf"],
"qualities": ["docs/json/sonarr/quality-size"],
"naming": ["docs/json/sonarr/naming"]
}
},
"recyclarr": {
"templates": "docs/recyclarr-configs"
}
}
""";
[Test, AutoMockData]
public void Naming_is_parsed(
[Frozen] ITrashGuidesRepo repo,
MockFileSystem fs,
TrashRepoMetadataBuilder sut)
{
fs.AddFile(repo.Path.File("metadata.json"), new MockFileData(MetadataJson));
var result = sut.GetMetadata();
result.JsonPaths.Radarr.Naming.Should().BeEquivalentTo("docs/json/radarr/naming");
result.JsonPaths.Sonarr.Naming.Should().BeEquivalentTo("docs/json/sonarr/naming");
}
}
Loading…
Cancel
Save