From 13b8e5679e54178ace12c9da6f911130e0bf56f1 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Tue, 26 Sep 2023 16:02:51 -0500 Subject: [PATCH] feat: Naming Sync Fixes #179 --- CHANGELOG.md | 4 + schemas/config-schema.json | 6 + schemas/config/media-naming-radarr.json | 17 + schemas/config/media-naming-sonarr.json | 20 + src/Recyclarr.Cli/CompositionRoot.cs | 5 +- .../MediaNaming/MediaNamingAutofacModule.cs | 21 + .../MediaNaming/MediaNamingSyncPipeline.cs | 47 ++ .../MediaNamingApiFetchPhase.cs | 19 + .../MediaNamingApiPersistencePhase.cs | 19 + .../PipelinePhases/MediaNamingConfigPhase.cs | 126 ++++ .../PipelinePhases/MediaNamingPhaseLogger.cs | 62 ++ .../PipelinePhases/MediaNamingPreviewPhase.cs | 61 ++ .../MediaNamingTransactionPhase.cs | 55 ++ .../Models/RadarrConfiguration.cs | 14 + .../Models/SonarrConfiguration.cs | 32 +- .../Parsing/ConfigYamlDataObjects.Radarr.cs | 23 + .../Parsing/ConfigYamlDataObjects.Sonarr.cs | 43 ++ .../Parsing/ConfigYamlDataObjects.cs | 27 - .../Parsing/ConfigYamlMapperProfile.cs | 16 +- .../ConfigMerging/RadarrConfigMerger.cs | 25 + .../ConfigMerging/SonarrConfigMerger.cs | 25 +- src/Recyclarr.Repo/RepoMetadata.cs | 2 + .../ApiServicesAutofacModule.cs | 5 +- .../MediaNaming/IMediaNamingApiService.cs | 9 + .../MediaNaming/MediaNamingApiService.cs | 35 ++ .../MediaNaming/MediaNamingDto.cs | 6 + .../MediaNaming/MediaNamingDtoExtensions.cs | 73 +++ .../MediaNaming/RadarrMediaNamingDto.cs | 32 + .../MediaNaming/SonarrMediaNamingDto.cs | 53 ++ .../GuideAutofacModule.cs | 4 + .../MediaNaming/IMediaNamingGuideService.cs | 7 + .../MediaNaming/MediaNamingGuideService.cs | 65 ++ .../MediaNaming/RadarrMediaNamingData.cs | 7 + .../MediaNaming/SonarrMediaNamingData.cs | 15 + .../MediaNaming/MediaNamingConfigPhaseTest.cs | 282 +++++++++ .../MediaNamingTransactionPhaseRadarrTest.cs | 109 ++++ .../MediaNamingTransactionPhaseSonarrTest.cs | 130 ++++ .../TrashGuide/Data/radarr_naming1.json | 8 + .../TrashGuide/Data/radarr_naming2.json | 10 + .../TrashGuide/Data/sonarr_naming1.json | 19 + .../TrashGuide/Data/sonarr_naming2.json | 17 + .../TrashGuide/MediaNamingGuideServiceTest.cs | 110 ++++ .../ConfigMerging/MergeApiKeyTest.cs | 65 ++ .../ConfigMerging/MergeBaseUrlTest.cs | 65 ++ .../ConfigMerging/MergeCustomFormatsTest.cs | 190 ++++++ .../MergeMediaNamingRadarrTest.cs | 97 +++ .../MergeMediaNamingSonarrTest.cs | 109 ++++ .../MergeQualityDefinitionTest.cs | 79 +++ .../ConfigMerging/MergeQualityProfilesTest.cs | 221 +++++++ ...gerTest.cs => MergeReleaseProfilesTest.cs} | 11 +- .../ConfigMerging/ServiceConfigMergerTest.cs | 595 ------------------ .../Recyclarr.Tests/Recyclarr.Tests.csproj | 3 + .../Repo/TrashRepoMetadataBuilderTest.cs | 46 ++ 53 files changed, 2502 insertions(+), 644 deletions(-) create mode 100644 schemas/config/media-naming-radarr.json create mode 100644 schemas/config/media-naming-sonarr.json create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingAutofacModule.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingSyncPipeline.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiFetchPhase.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiPersistencePhase.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingConfigPhase.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPhaseLogger.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPreviewPhase.cs create mode 100644 src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingTransactionPhase.cs create mode 100644 src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Radarr.cs create mode 100644 src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Sonarr.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/IMediaNamingApiService.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/MediaNamingApiService.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDto.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDtoExtensions.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/RadarrMediaNamingDto.cs create mode 100644 src/Recyclarr.ServarrApi/MediaNaming/SonarrMediaNamingDto.cs create mode 100644 src/Recyclarr.TrashGuide/MediaNaming/IMediaNamingGuideService.cs create mode 100644 src/Recyclarr.TrashGuide/MediaNaming/MediaNamingGuideService.cs create mode 100644 src/Recyclarr.TrashGuide/MediaNaming/RadarrMediaNamingData.cs create mode 100644 src/Recyclarr.TrashGuide/MediaNaming/SonarrMediaNamingData.cs create mode 100644 src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingConfigPhaseTest.cs create mode 100644 src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseRadarrTest.cs create mode 100644 src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseSonarrTest.cs create mode 100644 src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming1.json create mode 100644 src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming2.json create mode 100644 src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming1.json create mode 100644 src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming2.json create mode 100644 src/tests/Recyclarr.IntegrationTests/TrashGuide/MediaNamingGuideServiceTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeApiKeyTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeBaseUrlTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeCustomFormatsTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingRadarrTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingSonarrTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityDefinitionTest.cs create mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityProfilesTest.cs rename src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/{SonarrConfigMergerTest.cs => MergeReleaseProfilesTest.cs} (91%) delete mode 100644 src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs create mode 100644 src/tests/Recyclarr.Tests/Repo/TrashRepoMetadataBuilderTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f4bb5060..0f97f821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/schemas/config-schema.json b/schemas/config-schema.json index 23871274..513cac61 100644 --- a/schemas/config-schema.json +++ b/schemas/config-schema.json @@ -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" } } } diff --git a/schemas/config/media-naming-radarr.json b/schemas/config/media-naming-radarr.json new file mode 100644 index 00000000..8c4a204d --- /dev/null +++ b/schemas/config/media-naming-radarr.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" } + } + } + } +} diff --git a/schemas/config/media-naming-sonarr.json b/schemas/config/media-naming-sonarr.json new file mode 100644 index 00000000..f03248a9 --- /dev/null +++ b/schemas/config/media-naming-sonarr.json @@ -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" } + } + } + } +} diff --git a/src/Recyclarr.Cli/CompositionRoot.cs b/src/Recyclarr.Cli/CompositionRoot.cs index a36e8ed7..c19113aa 100644 --- a/src/Recyclarr.Cli/CompositionRoot.cs +++ b/src/Recyclarr.Cli/CompositionRoot.cs @@ -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(); builder.RegisterModule(); builder.RegisterModule(); + builder.RegisterModule(); builder.RegisterTypes( typeof(TagSyncPipeline), typeof(CustomFormatSyncPipeline), typeof(QualityProfileSyncPipeline), typeof(QualitySizeSyncPipeline), - typeof(ReleaseProfileSyncPipeline)) + typeof(ReleaseProfileSyncPipeline), + typeof(MediaNamingSyncPipeline)) .As() .OrderByRegistration(); } diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingAutofacModule.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingAutofacModule.cs new file mode 100644 index 00000000..2c5aa761 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingAutofacModule.cs @@ -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(); + builder.RegisterType(); + builder.RegisterType(); + builder.RegisterType(); + builder.RegisterType(); + builder.RegisterType(); + builder.RegisterType(); + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingSyncPipeline.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingSyncPipeline.cs new file mode 100644 index 00000000..4aacc876 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingSyncPipeline.cs @@ -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); + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiFetchPhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiFetchPhase.cs new file mode 100644 index 00000000..ceb30029 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiFetchPhase.cs @@ -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 Execute(IServiceConfiguration config) + { + return await _api.GetNaming(config); + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiPersistencePhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiPersistencePhase.cs new file mode 100644 index 00000000..2251c7a7 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingApiPersistencePhase.cs @@ -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); + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingConfigPhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingConfigPhase.cs new file mode 100644 index 00000000..77bd25db --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingConfigPhase.cs @@ -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 InvalidNaming { get; init; } = new List(); +} + +public class MediaNamingConfigPhase +{ + private readonly IMediaNamingGuideService _guide; + private readonly ISonarrCapabilityFetcher _sonarrCapabilities; + private List _errors = new(); + + public MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities) + { + _guide = guide; + _sonarrCapabilities = sonarrCapabilities; + } + + public async Task Execute(IServiceConfiguration config) + { + _errors = new List(); + + 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 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 guideFormats, + string? configFormatKey, + string errorDescription) + { + return ObtainFormat(guideFormats, configFormatKey, null, errorDescription); + } + + private string? ObtainFormat( + IReadOnlyDictionary 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 {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; + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPhaseLogger.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPhaseLogger.cs new file mode 100644 index 00000000..c01f9244 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPhaseLogger.cs @@ -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!"); + } + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPreviewPhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPreviewPhase.cs new file mode 100644 index 00000000..d92a35b9 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingPreviewPhase.cs @@ -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); + } +} diff --git a/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingTransactionPhase.cs b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingTransactionPhase.cs new file mode 100644 index 00000000..08b0a5b9 --- /dev/null +++ b/src/Recyclarr.Cli/Pipelines/MediaNaming/PipelinePhases/MediaNamingTransactionPhase.cs @@ -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; + } +} diff --git a/src/Recyclarr.Config/Models/RadarrConfiguration.cs b/src/Recyclarr.Config/Models/RadarrConfiguration.cs index cb1de2f9..2b3d2803 100644 --- a/src/Recyclarr.Config/Models/RadarrConfiguration.cs +++ b/src/Recyclarr.Config/Models/RadarrConfiguration.cs @@ -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(); } diff --git a/src/Recyclarr.Config/Models/SonarrConfiguration.cs b/src/Recyclarr.Config/Models/SonarrConfiguration.cs index ba5c1e3a..35b8f7e5 100644 --- a/src/Recyclarr.Config/Models/SonarrConfiguration.cs +++ b/src/Recyclarr.Config/Models/SonarrConfiguration.cs @@ -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 ReleaseProfiles { get; [UsedImplicitly] init; } = + public IList ReleaseProfiles { get; init; } = Array.Empty(); + + public SonarrMediaNamingConfig MediaNaming { get; init; } = new(); } public class ReleaseProfileConfig { - public IReadOnlyCollection TrashIds { get; [UsedImplicitly] init; } = Array.Empty(); - public bool StrictNegativeScores { get; [UsedImplicitly] init; } - public IReadOnlyCollection Tags { get; [UsedImplicitly] init; } = Array.Empty(); - public SonarrProfileFilterConfig? Filter { get; [UsedImplicitly] init; } + public IReadOnlyCollection TrashIds { get; init; } = Array.Empty(); + public bool StrictNegativeScores { get; init; } + public IReadOnlyCollection Tags { get; init; } = Array.Empty(); + public SonarrProfileFilterConfig? Filter { get; init; } } public class SonarrProfileFilterConfig { - public IReadOnlyCollection Include { get; [UsedImplicitly] init; } = Array.Empty(); - public IReadOnlyCollection Exclude { get; [UsedImplicitly] init; } = Array.Empty(); + public IReadOnlyCollection Include { get; init; } = Array.Empty(); + public IReadOnlyCollection Exclude { get; init; } = Array.Empty(); +} + +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; } } diff --git a/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Radarr.cs b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Radarr.cs new file mode 100644 index 00000000..42321ec6 --- /dev/null +++ b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Radarr.cs @@ -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; } +} diff --git a/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Sonarr.cs b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Sonarr.cs new file mode 100644 index 00000000..b36681e7 --- /dev/null +++ b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.Sonarr.cs @@ -0,0 +1,43 @@ +using JetBrains.Annotations; + +namespace Recyclarr.Config.Parsing; + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record ReleaseProfileFilterConfigYaml +{ + public IReadOnlyCollection? Include { get; init; } + public IReadOnlyCollection? Exclude { get; init; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record ReleaseProfileConfigYaml +{ + public IReadOnlyCollection? TrashIds { get; init; } + public bool StrictNegativeScores { get; init; } + public IReadOnlyCollection? 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? ReleaseProfiles { get; init; } + public SonarrMediaNamingConfigYaml? MediaNaming { get; init; } +} diff --git a/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.cs b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.cs index 4269d0a8..94485385 100644 --- a/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.cs +++ b/src/Recyclarr.Config/Parsing/ConfigYamlDataObjects.cs @@ -79,33 +79,6 @@ public record ServiceConfigYaml public IReadOnlyCollection? Include { get; init; } } -[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] -public record ReleaseProfileFilterConfigYaml -{ - public IReadOnlyCollection? Include { get; init; } - public IReadOnlyCollection? Exclude { get; init; } -} - -[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] -public record ReleaseProfileConfigYaml -{ - public IReadOnlyCollection? TrashIds { get; init; } - public bool StrictNegativeScores { get; init; } - public IReadOnlyCollection? 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? ReleaseProfiles { get; init; } -} - [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] public record RootConfigYaml { diff --git a/src/Recyclarr.Config/Parsing/ConfigYamlMapperProfile.cs b/src/Recyclarr.Config/Parsing/ConfigYamlMapperProfile.cs index 8c0bf506..e335c68f 100644 --- a/src/Recyclarr.Config/Parsing/ConfigYamlMapperProfile.cs +++ b/src/Recyclarr.Config/Parsing/ConfigYamlMapperProfile.cs @@ -14,11 +14,17 @@ public class ConfigYamlMapperProfile : Profile CreateMap(); CreateMap(); CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + + CreateMap(); + CreateMap(); + CreateMap() .ForMember(x => x.Enabled, o => o.NullSubstitute(true)); - CreateMap(); - CreateMap() .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() - .IncludeBase(); + .IncludeBase() + .ForMember(x => x.MediaNaming, o => o.UseDestinationValue()); CreateMap() - .IncludeBase(); + .IncludeBase() + .ForMember(x => x.MediaNaming, o => o.UseDestinationValue()); } } diff --git a/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs b/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs index 7161a229..41f326c3 100644 --- a/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs +++ b/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs @@ -1,5 +1,30 @@ +using System.Diagnostics.CodeAnalysis; + namespace Recyclarr.Config.Parsing.PostProcessing.ConfigMerging; public class RadarrConfigMerger : ServiceConfigMerger { + 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 + }) + }; + } } diff --git a/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs b/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs index 02f155b3..32e143e2 100644 --- a/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs +++ b/src/Recyclarr.Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs @@ -1,3 +1,5 @@ +using System.Diagnostics.CodeAnalysis; + namespace Recyclarr.Config.Parsing.PostProcessing.ConfigMerging; public class SonarrConfigMerger : ServiceConfigMerger @@ -7,7 +9,28 @@ public class SonarrConfigMerger : ServiceConfigMerger 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 + }) }; } } diff --git a/src/Recyclarr.Repo/RepoMetadata.cs b/src/Recyclarr.Repo/RepoMetadata.cs index 6dfc0544..b149de16 100644 --- a/src/Recyclarr.Repo/RepoMetadata.cs +++ b/src/Recyclarr.Repo/RepoMetadata.cs @@ -4,6 +4,7 @@ public record RadarrMetadata { public IReadOnlyCollection CustomFormats { get; init; } = Array.Empty(); public IReadOnlyCollection Qualities { get; init; } = Array.Empty(); + public IReadOnlyCollection Naming { get; init; } = Array.Empty(); } public record SonarrMetadata @@ -11,6 +12,7 @@ public record SonarrMetadata public IReadOnlyCollection ReleaseProfiles { get; init; } = Array.Empty(); public IReadOnlyCollection Qualities { get; init; } = Array.Empty(); public IReadOnlyCollection CustomFormats { get; init; } = Array.Empty(); + public IReadOnlyCollection Naming { get; init; } = Array.Empty(); } public record JsonPaths diff --git a/src/Recyclarr.ServarrApi/ApiServicesAutofacModule.cs b/src/Recyclarr.ServarrApi/ApiServicesAutofacModule.cs index 4bfb1e5f..f4f66601 100644 --- a/src/Recyclarr.ServarrApi/ApiServicesAutofacModule.cs +++ b/src/Recyclarr.ServarrApi/ApiServicesAutofacModule.cs @@ -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().As(); builder.RegisterType().As().SingleInstance(); + + builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); + builder.RegisterType().As(); } } diff --git a/src/Recyclarr.ServarrApi/MediaNaming/IMediaNamingApiService.cs b/src/Recyclarr.ServarrApi/MediaNaming/IMediaNamingApiService.cs new file mode 100644 index 00000000..c280683a --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/IMediaNamingApiService.cs @@ -0,0 +1,9 @@ +using Recyclarr.Config.Models; + +namespace Recyclarr.ServarrApi.MediaNaming; + +public interface IMediaNamingApiService +{ + Task GetNaming(IServiceConfiguration config); + Task UpdateNaming(IServiceConfiguration config, MediaNamingDto dto); +} diff --git a/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingApiService.cs b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingApiService.cs new file mode 100644 index 00000000..8d02f5fd --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingApiService.cs @@ -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 GetNaming(IServiceConfiguration config) + { + var response = await _service.Request(config, "config", "naming") + .GetAsync(); + + return config.ServiceType switch + { + SupportedServices.Radarr => await response.GetJsonAsync(), + SupportedServices.Sonarr => await response.GetJsonAsync(), + _ => 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); + } +} diff --git a/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDto.cs b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDto.cs new file mode 100644 index 00000000..80dfdcaf --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDto.cs @@ -0,0 +1,6 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Recyclarr.ServarrApi.MediaNaming; + +[SuppressMessage("SonarLint", "S2094")] +public abstract record MediaNamingDto; diff --git a/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDtoExtensions.cs b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDtoExtensions.cs new file mode 100644 index 00000000..b16ce661 --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/MediaNamingDtoExtensions.cs @@ -0,0 +1,73 @@ +namespace Recyclarr.ServarrApi.MediaNaming; + +public static class MediaNamingDtoExtensions +{ + public static IReadOnlyCollection GetDifferences(this RadarrMediaNamingDto left, MediaNamingDto other) + { + var diff = new List(); + + 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 GetDifferences(this SonarrMediaNamingDto left, MediaNamingDto other) + { + var diff = new List(); + + 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; + } +} diff --git a/src/Recyclarr.ServarrApi/MediaNaming/RadarrMediaNamingDto.cs b/src/Recyclarr.ServarrApi/MediaNaming/RadarrMediaNamingDto.cs new file mode 100644 index 00000000..3b044603 --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/RadarrMediaNamingDto.cs @@ -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 ExtraJson { get; init; } = new(); +} diff --git a/src/Recyclarr.ServarrApi/MediaNaming/SonarrMediaNamingDto.cs b/src/Recyclarr.ServarrApi/MediaNaming/SonarrMediaNamingDto.cs new file mode 100644 index 00000000..2aff63d7 --- /dev/null +++ b/src/Recyclarr.ServarrApi/MediaNaming/SonarrMediaNamingDto.cs @@ -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 ExtraJson { get; init; } = new(); +} diff --git a/src/Recyclarr.TrashGuide/GuideAutofacModule.cs b/src/Recyclarr.TrashGuide/GuideAutofacModule.cs index af639bd1..4dba2574 100644 --- a/src/Recyclarr.TrashGuide/GuideAutofacModule.cs +++ b/src/Recyclarr.TrashGuide/GuideAutofacModule.cs @@ -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().As().SingleInstance(); builder.RegisterType(); + + // Media Naming + builder.RegisterType().As(); } } diff --git a/src/Recyclarr.TrashGuide/MediaNaming/IMediaNamingGuideService.cs b/src/Recyclarr.TrashGuide/MediaNaming/IMediaNamingGuideService.cs new file mode 100644 index 00000000..f96daaff --- /dev/null +++ b/src/Recyclarr.TrashGuide/MediaNaming/IMediaNamingGuideService.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.TrashGuide.MediaNaming; + +public interface IMediaNamingGuideService +{ + RadarrMediaNamingData GetRadarrNamingData(); + SonarrMediaNamingData GetSonarrNamingData(); +} diff --git a/src/Recyclarr.TrashGuide/MediaNaming/MediaNamingGuideService.cs b/src/Recyclarr.TrashGuide/MediaNaming/MediaNamingGuideService.cs new file mode 100644 index 00000000..5d1e3cf6 --- /dev/null +++ b/src/Recyclarr.TrashGuide/MediaNaming/MediaNamingGuideService.cs @@ -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 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 JoinDictionaries( + IEnumerable> 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(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(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)) + } + }; + } +} diff --git a/src/Recyclarr.TrashGuide/MediaNaming/RadarrMediaNamingData.cs b/src/Recyclarr.TrashGuide/MediaNaming/RadarrMediaNamingData.cs new file mode 100644 index 00000000..4277a4a1 --- /dev/null +++ b/src/Recyclarr.TrashGuide/MediaNaming/RadarrMediaNamingData.cs @@ -0,0 +1,7 @@ +namespace Recyclarr.TrashGuide.MediaNaming; + +public record RadarrMediaNamingData +{ + public IReadOnlyDictionary Folder { get; init; } = new Dictionary(); + public IReadOnlyDictionary File { get; init; } = new Dictionary(); +} diff --git a/src/Recyclarr.TrashGuide/MediaNaming/SonarrMediaNamingData.cs b/src/Recyclarr.TrashGuide/MediaNaming/SonarrMediaNamingData.cs new file mode 100644 index 00000000..b2a573ec --- /dev/null +++ b/src/Recyclarr.TrashGuide/MediaNaming/SonarrMediaNamingData.cs @@ -0,0 +1,15 @@ +namespace Recyclarr.TrashGuide.MediaNaming; + +public record SonarrEpisodeNamingData +{ + public IReadOnlyDictionary Standard { get; init; } = new Dictionary(); + public IReadOnlyDictionary Daily { get; init; } = new Dictionary(); + public IReadOnlyDictionary Anime { get; init; } = new Dictionary(); +} + +public record SonarrMediaNamingData +{ + public IReadOnlyDictionary Season { get; init; } = new Dictionary(); + public IReadOnlyDictionary Series { get; init; } = new Dictionary(); + public SonarrEpisodeNamingData Episodes { get; init; } = new(); +} diff --git a/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingConfigPhaseTest.cs b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingConfigPhaseTest.cs new file mode 100644 index 00000000..32d49cae --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingConfigPhaseTest.cs @@ -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 + { + {"default", "season_default"} + }, + Series = new Dictionary + { + {"default", "series_default"}, + {"plex", "series_plex"}, + {"emby", "series_emby"} + }, + Episodes = new SonarrEpisodeNamingData + { + Standard = new Dictionary + { + {"default:3", "episodes_standard_default_3"}, + {"default:4", "episodes_standard_default_4"}, + {"original", "episodes_standard_original"} + }, + Daily = new Dictionary + { + {"default:3", "episodes_daily_default_3"}, + {"default:4", "episodes_daily_default_4"}, + {"original", "episodes_daily_original"} + }, + Anime = new Dictionary + { + {"default:3", "episodes_anime_default_3"}, + {"default:4", "episodes_anime_default_4"} + } + } + }; + + private static readonly RadarrMediaNamingData RadarrNamingData = new() + { + Folder = new Dictionary + { + {"default", "folder_default"}, + {"plex", "folder_plex"}, + {"emby", "folder_emby"} + }, + File = new Dictionary + { + {"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(); + } + + [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() + .Which.MovieFolderFormat.Should().BeNull(); + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseRadarrTest.cs b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseRadarrTest.cs new file mode 100644 index 00000000..e2a28bdb --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseRadarrTest.cs @@ -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()); + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseSonarrTest.cs b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseSonarrTest.cs new file mode 100644 index 00000000..e9a0f7b3 --- /dev/null +++ b/src/tests/Recyclarr.Cli.Tests/Pipelines/MediaNaming/MediaNamingTransactionPhaseSonarrTest.cs @@ -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()); + } +} diff --git a/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming1.json b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming1.json new file mode 100644 index 00000000..e912677c --- /dev/null +++ b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming1.json @@ -0,0 +1,8 @@ +{ + "folder": { + "plex": "folder_plex" + }, + "file": { + "emby": "file_emby" + } +} diff --git a/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming2.json b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming2.json new file mode 100644 index 00000000..d2a6473c --- /dev/null +++ b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/radarr_naming2.json @@ -0,0 +1,10 @@ +{ + "folder": { + "default": "folder_default", + "emby": "folder_emby" + }, + "file": { + "default": "file_default", + "jellyfin": "file_jellyfin" + } +} diff --git a/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming1.json b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming1.json new file mode 100644 index 00000000..27051b7e --- /dev/null +++ b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming1.json @@ -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" + } + } +} diff --git a/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming2.json b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming2.json new file mode 100644 index 00000000..8a0687e2 --- /dev/null +++ b/src/tests/Recyclarr.IntegrationTests/TrashGuide/Data/sonarr_naming2.json @@ -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" + } + } +} diff --git a/src/tests/Recyclarr.IntegrationTests/TrashGuide/MediaNamingGuideServiceTest.cs b/src/tests/Recyclarr.IntegrationTests/TrashGuide/MediaNamingGuideServiceTest.cs new file mode 100644 index 00000000..a2ce4678 --- /dev/null +++ b/src/tests/Recyclarr.IntegrationTests/TrashGuide/MediaNamingGuideServiceTest.cs @@ -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(); + 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(); + 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(); + + var result = sut.GetRadarrNamingData(); + result.Should().BeEquivalentTo(new RadarrMediaNamingData + { + Folder = new Dictionary + { + {"default", "folder_default"}, + {"plex", "folder_plex"}, + {"emby", "folder_emby"} + }, + File = new Dictionary + { + {"default", "file_default"}, + {"emby", "file_emby"}, + {"jellyfin", "file_jellyfin"} + } + }); + } + + [Test] + public void Sonarr_naming() + { + SetupMetadata(); + + var repo = Resolve(); + 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(); + + var result = sut.GetSonarrNamingData(); + result.Should().BeEquivalentTo(new SonarrMediaNamingData + { + Season = new Dictionary + { + {"default", "season_default"} + }, + Series = new Dictionary + { + {"default", "series_default"}, + {"plex", "series_plex"}, + {"emby", "series_emby"} + }, + Episodes = new SonarrEpisodeNamingData + { + Standard = new Dictionary + { + {"default:3", "episodes_standard_default_3"}, + {"default:4", "episodes_standard_default_4"}, + {"original", "episodes_standard_original"} + }, + Daily = new Dictionary + { + {"default:3", "episodes_daily_default_3"}, + {"default:4", "episodes_daily_default_4"}, + {"original", "episodes_daily_original"} + }, + Anime = new Dictionary + { + {"default:3", "episodes_anime_default_3"}, + {"default:4", "episodes_anime_default_4"} + } + } + }); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeApiKeyTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeApiKeyTest.cs new file mode 100644 index 00000000..5cdd9935 --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeApiKeyTest.cs @@ -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); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeBaseUrlTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeBaseUrlTest.cs new file mode 100644 index 00000000..e2417185 --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeBaseUrlTest.cs @@ -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); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeCustomFormatsTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeCustomFormatsTest.cs new file mode 100644 index 00000000..e21cffc5 --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeCustomFormatsTest.cs @@ -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} + } + } + } + }); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingRadarrTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingRadarrTest.cs new file mode 100644 index 00000000..8534fdff --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingRadarrTest.cs @@ -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); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingSonarrTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingSonarrTest.cs new file mode 100644 index 00000000..0560e995 --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeMediaNamingSonarrTest.cs @@ -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); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityDefinitionTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityDefinitionTest.cs new file mode 100644 index 00000000..25109065 --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityDefinitionTest.cs @@ -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); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityProfilesTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityProfilesTest.cs new file mode 100644 index 00000000..ccefccab --- /dev/null +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeQualityProfilesTest.cs @@ -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"} + } + } + } + } + }); + } +} diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeReleaseProfilesTest.cs similarity index 91% rename from src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs rename to src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeReleaseProfilesTest.cs index 33f83ee1..5ae3c8d3 100644 --- a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs +++ b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/MergeReleaseProfilesTest.cs @@ -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() diff --git a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs b/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs deleted file mode 100644 index 568ab220..00000000 --- a/src/tests/Recyclarr.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs +++ /dev/null @@ -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"} - } - } - } - } - }); - } -} diff --git a/src/tests/Recyclarr.Tests/Recyclarr.Tests.csproj b/src/tests/Recyclarr.Tests/Recyclarr.Tests.csproj index b936cea4..38c7e623 100644 --- a/src/tests/Recyclarr.Tests/Recyclarr.Tests.csproj +++ b/src/tests/Recyclarr.Tests/Recyclarr.Tests.csproj @@ -8,4 +8,7 @@ + + + diff --git a/src/tests/Recyclarr.Tests/Repo/TrashRepoMetadataBuilderTest.cs b/src/tests/Recyclarr.Tests/Repo/TrashRepoMetadataBuilderTest.cs new file mode 100644 index 00000000..e207631e --- /dev/null +++ b/src/tests/Recyclarr.Tests/Repo/TrashRepoMetadataBuilderTest.cs @@ -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"); + } +}