Skip to content

Commit

Permalink
feat: Naming Sync
Browse files Browse the repository at this point in the history
Fixes #179
  • Loading branch information
rcdailey committed Sep 26, 2023
1 parent bc485a8 commit 522086e
Show file tree
Hide file tree
Showing 39 changed files with 1,506 additions and 41 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion src/Recyclarr.Cli/CompositionRoot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
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;
Expand Down Expand Up @@ -74,13 +75,15 @@ private static void PipelineRegistrations(ContainerBuilder builder)
builder.RegisterModule<QualityProfileAutofacModule>();
builder.RegisterModule<QualitySizeAutofacModule>();
builder.RegisterModule<ReleaseProfileAutofacModule>();
builder.RegisterModule<MediaNamingAutofacModule>();

builder.RegisterTypes(
typeof(TagSyncPipeline),
typeof(CustomFormatSyncPipeline),
typeof(QualityProfileSyncPipeline),
typeof(QualitySizeSyncPipeline),
typeof(ReleaseProfileSyncPipeline))
typeof(ReleaseProfileSyncPipeline),
typeof(MediaNamingSyncPipeline))
.As<ISyncPipeline>()
.OrderByRegistration();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using Autofac;
using Autofac.Extras.AggregateService;
using Recyclarr.Cli.Pipelines.MediaNaming.PipelinePhases;

namespace Recyclarr.Cli.Pipelines.MediaNaming;

public class MediaNamingAutofacModule : Module
{
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);

builder.RegisterAggregateService<IMediaNamingPipelinePhases>();
builder.RegisterType<MediaNamingConfigPhase>();
builder.RegisterType<MediaNamingApiFetchPhase>();
builder.RegisterType<MediaNamingTransactionPhase>();
builder.RegisterType<MediaNamingPreviewPhase>();
builder.RegisterType<MediaNamingApiPersistencePhase>();
builder.RegisterType<MediaNamingPhaseLogger>();
}
}
47 changes: 47 additions & 0 deletions src/Recyclarr.Cli/Pipelines/MediaNaming/MediaNamingSyncPipeline.cs
Original file line number Diff line number Diff line change
@@ -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(transactions);
}
}
Original file line number Diff line number Diff line change
@@ -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<IMediaNamingDto> Execute(IServiceConfiguration config)
{
return await _api.GetNaming(config);
}
}
Original file line number Diff line number Diff line change
@@ -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, IMediaNamingDto serviceDto)
{
await _api.UpdateNaming(config, serviceDto);
}
}
Original file line number Diff line number Diff line change
@@ -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 IMediaNamingDto Dto { get; init; }
public IReadOnlyCollection<InvalidNamingConfig> InvalidNaming { get; init; } = new List<InvalidNamingConfig>();
}

public class MediaNamingConfigPhase
{
private readonly IMediaNamingGuideService _guide;
private readonly ISonarrCapabilityFetcher _sonarrCapabilities;
private List<InvalidNamingConfig> _errors = new();

public MediaNamingConfigPhase(IMediaNamingGuideService guide, ISonarrCapabilityFetcher sonarrCapabilities)
{
_guide = guide;
_sonarrCapabilities = sonarrCapabilities;
}

public async Task<ProcessedNamingConfig> Execute(IServiceConfiguration config)
{
_errors = new List<InvalidNamingConfig>();

var dto = config switch
{
RadarrConfiguration c => ProcessRadarrNaming(c),
SonarrConfiguration c => await ProcessSonarrNaming(c),
_ => throw new ArgumentException("Configuration type unsupported for naming sync")
};

return new ProcessedNamingConfig {Dto = dto, InvalidNaming = _errors};
}

private IMediaNamingDto 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<IMediaNamingDto> ProcessSonarrNaming(SonarrConfiguration config)
{
var guideData = _guide.GetSonarrNamingData();
var configData = config.MediaNaming;
var capabilities = await _sonarrCapabilities.GetCapabilities(config);
var keySuffix = capabilities.SupportsCustomFormats ? ":4" : ":3";

return new SonarrMediaNamingDto
{
SeasonFolderFormat = ObtainFormat(guideData.Season, configData.Season, "Season Folder Format"),
SeriesFolderFormat = ObtainFormat(guideData.Series, configData.Series, "Series Folder Format"),
StandardEpisodeFormat = ObtainFormat(
guideData.Episodes.Standard,
configData.Episodes?.Standard,
keySuffix,
"Standard Episode Format"),
DailyEpisodeFormat = ObtainFormat(
guideData.Episodes.Daily,
configData.Episodes?.Daily,
keySuffix,
"Daily Episode Format"),
AnimeEpisodeFormat = ObtainFormat(
guideData.Episodes.Anime,
configData.Episodes?.Anime,
keySuffix,
"Anime Episode Format"),
RenameEpisodes = configData.Episodes?.Rename
};
}

private string? ObtainFormat(
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string errorDescription)
{
return ObtainFormat(guideFormats, configFormatKey, null, errorDescription);
}

private string? ObtainFormat(
IReadOnlyDictionary<string, string> guideFormats,
string? configFormatKey,
string? keySuffix,
string errorDescription)
{
if (configFormatKey is null)
{
return null;
}

// Use lower-case for the config value because System.Text.Json doesn't let us create a case-insensitive
// dictionary. The MediaNamingGuideService converts all parsed guide JSON keys to lower case.
var lowerKey = configFormatKey.ToLowerInvariant();

var keys = new List<string> {lowerKey};
if (keySuffix is not null)
{
// Put the more specific key first
keys.Insert(0, lowerKey + keySuffix);
}

foreach (var k in keys)
{
if (guideFormats.TryGetValue(k, out var format))
{
return format;
}
}

_errors.Add(new InvalidNamingConfig(errorDescription, configFormatKey));
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 hasChanges = config.Dto switch
{
RadarrMediaNamingDto dto => RadarrHasChanges(dto),
SonarrMediaNamingDto dto => SonarrHasChanges(dto),
_ => throw new ArgumentException("Unsupported configuration type in LogConfigPhase method")
};

if (!hasChanges)
{
_log.Debug("No media naming changes to process");
return true;
}

return false;
}

private static bool SonarrHasChanges(SonarrMediaNamingDto dto)
{
return dto != new SonarrMediaNamingDto();
}

private static bool RadarrHasChanges(RadarrMediaNamingDto dto)
{
return dto != new RadarrMediaNamingDto();
}

public void LogPersistenceResults(IMediaNamingDto transactions)
{
throw new NotImplementedException();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
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(IMediaNamingDto serviceDto)
{
_table = new Table {Border = TableBorder.Simple}
.Title("Radarr Media Naming")
.AddColumns("Field", "Format");

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.Write(_table);
}

private void AddRow(string field, object? value)
{
_table?.AddRow(field, value?.ToString() ?? "UNSET");
}

private void PreviewRadarr(RadarrMediaNamingDto dto)
{
AddRow("Enable Movie Renames?", dto.RenameMovies);
AddRow("Movie", dto.StandardMovieFormat);
AddRow("Folder", dto.MovieFolderFormat);
}

private void PreviewSonarr(SonarrMediaNamingDto dto)
{
AddRow("Enable Episode Renames?", dto.RenameEpisodes);
AddRow("Series Folder", dto.SeriesFolderFormat);
AddRow("Season Folder", dto.SeasonFolderFormat);
AddRow("Standard Episodes", dto.StandardEpisodeFormat);
AddRow("Daily Episodes", dto.DailyEpisodeFormat);
AddRow("Anime Episodes", dto.AnimeEpisodeFormat);
}
}

0 comments on commit 522086e

Please sign in to comment.