Add configurable indexer name template for external applications

Implements a new IndexerNameTemplateService that allows users to customize how indexer names appear in synced external applications (Sonarr, Radarr, etc.). The template supports {name} and {instance} placeholders, with a default format of "{name} ({instance})".

- Add IndexerNameTemplate configuration setting with UI control
- Create IndexerNameTemplateService with comprehensive test coverage
- Update all application sync classes to use template service
- Add frontend validation warning when {name} placeholder is missing
- Maintain backward compatibility with consistent null handling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Daniel Sousa 2025-07-07 16:21:37 +01:00
parent 86b81948af
commit 128312b8ed
14 changed files with 187 additions and 17 deletions

View file

@ -22,6 +22,7 @@ function HostSettings(props) {
urlBase,
instanceName,
applicationUrl,
indexerNameTemplate,
enableSsl,
sslPort,
sslCertPath,
@ -105,6 +106,25 @@ function HostSettings(props) {
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}
>
<FormLabel>{translate('IndexerNameTemplate')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="indexerNameTemplate"
helpText={translate('IndexerNameTemplateHelpText')}
helpTextWarning={
indexerNameTemplate && indexerNameTemplate.value &&
!indexerNameTemplate.value.includes('{name}') ? translate('IndexerNameTemplateHelpTextWarning') : undefined
}
onChange={onInputChange}
{...indexerNameTemplate}
/>
</FormGroup>
<FormGroup
advancedSettings={advancedSettings}
isAdvanced={true}

View file

@ -0,0 +1,78 @@
using FluentAssertions;
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Applications;
using NzbDrone.Core.Configuration;
using NzbDrone.Test.Common;
using static NzbDrone.Core.Applications.IndexerNameTemplateDefaults;
namespace NzbDrone.Core.Test.IndexerTests
{
[TestFixture]
public class IndexerNameTemplateServiceFixture : TestBase<IndexerNameTemplateService>
{
private Mock<IConfigService> _configService;
[SetUp]
public void Setup()
{
_configService = Mocker.GetMock<IConfigService>();
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void FormatIndexerName_should_return_empty_when_invalid_input(string indexerName)
{
_configService.Setup(s => s.IndexerNameTemplate).Returns("{name} ({instance})");
var result = Subject.FormatIndexerName(indexerName, "Prowlarr");
result.Should().Be("");
}
[Test]
public void FormatIndexerName_should_format_with_template()
{
_configService.Setup(s => s.IndexerNameTemplate).Returns("{name} ({instance})");
var result = Subject.FormatIndexerName("MyIndexer", "MyProwlarr");
result.Should().Be("MyIndexer (MyProwlarr)");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void FormatIndexerName_should_use_fallback_instance_name(string instanceName)
{
_configService.Setup(s => s.IndexerNameTemplate).Returns("{name} ({instance})");
var result = Subject.FormatIndexerName("MyIndexer", instanceName);
result.Should().Be($"MyIndexer ({DefaultInstanceName})");
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public void FormatIndexerName_should_return_original_name_when_no_template(string template)
{
_configService.Setup(s => s.IndexerNameTemplate).Returns(template);
var result = Subject.FormatIndexerName("MyIndexer", "MyProwlarr");
result.Should().Be("MyIndexer");
}
[Test]
public void FormatIndexerName_should_handle_custom_template()
{
_configService.Setup(s => s.IndexerNameTemplate).Returns("[{instance}] {name}");
var result = Subject.FormatIndexerName("MyIndexer", "MyProwlarr");
result.Should().Be("[MyProwlarr] MyIndexer");
}
}
}

View file

@ -0,0 +1,46 @@
using NzbDrone.Core.Configuration;
namespace NzbDrone.Core.Applications
{
public static class IndexerNameTemplateDefaults
{
public const string DefaultTemplate = "{name} ({instance})";
public const string DefaultInstanceName = "Prowlarr";
}
public interface IIndexerNameTemplateService
{
string FormatIndexerName(string indexerName, string instanceName);
}
public class IndexerNameTemplateService : IIndexerNameTemplateService
{
private readonly IConfigService _configService;
public IndexerNameTemplateService(IConfigService configService)
{
_configService = configService;
}
public string FormatIndexerName(string indexerName, string instanceName)
{
if (string.IsNullOrWhiteSpace(indexerName))
{
return string.Empty;
}
var template = _configService.IndexerNameTemplate;
if (string.IsNullOrWhiteSpace(template))
{
return indexerName;
}
var finalInstanceName = !string.IsNullOrWhiteSpace(instanceName) ? instanceName : IndexerNameTemplateDefaults.DefaultInstanceName;
return template
.Replace("{name}", indexerName)
.Replace("{instance}", finalInstanceName);
}
}
}

View file

@ -15,12 +15,14 @@ public class LazyLibrarian : ApplicationBase<LazyLibrarianSettings>
private readonly ILazyLibrarianV1Proxy _lazyLibrarianV1Proxy;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public LazyLibrarian(ILazyLibrarianV1Proxy lazyLibrarianV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public LazyLibrarian(ILazyLibrarianV1Proxy lazyLibrarianV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_lazyLibrarianV1Proxy = lazyLibrarianV1Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -155,8 +157,8 @@ private LazyLibrarianIndexer BuildLazyLibrarianIndexer(IndexerDefinition indexer
var lazyLibrarianIndexer = new LazyLibrarianIndexer
{
Name = originalName ?? $"{indexer.Name} (Prowlarr)",
Altername = $"{indexer.Name} (Prowlarr)",
Name = originalName ?? _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
Altername = originalName ?? _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api",
Apikey = _configFileProvider.ApiKey,
Categories = string.Join(",", indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())),

View file

@ -21,13 +21,15 @@ public class Lidarr : ApplicationBase<LidarrSettings>
private readonly ILidarrV1Proxy _lidarrV1Proxy;
private readonly ICached<List<LidarrIndexer>> _schemaCache;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Lidarr(ICacheManager cacheManager, ILidarrV1Proxy lidarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Lidarr(ICacheManager cacheManager, ILidarrV1Proxy lidarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_schemaCache = cacheManager.GetCache<List<LidarrIndexer>>(GetType());
_lidarrV1Proxy = lidarrV1Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -247,7 +249,7 @@ private LidarrIndexer BuildLidarrIndexer(IndexerDefinition indexer, IndexerCapab
var lidarrIndexer = new LidarrIndexer
{
Id = id,
Name = $"{indexer.Name} (Prowlarr)",
Name = _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss,
EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch,
EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch,

View file

@ -15,12 +15,14 @@ public class Mylar : ApplicationBase<MylarSettings>
private readonly IMylarV3Proxy _mylarV3Proxy;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Mylar(IMylarV3Proxy mylarV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Mylar(IMylarV3Proxy mylarV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_mylarV3Proxy = mylarV3Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -155,8 +157,8 @@ private MylarIndexer BuildMylarIndexer(IndexerDefinition indexer, IndexerCapabil
var mylarIndexer = new MylarIndexer
{
Name = originalName ?? $"{indexer.Name} (Prowlarr)",
Altername = $"{indexer.Name} (Prowlarr)",
Name = originalName ?? _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
Altername = originalName ?? _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
Host = $"{Settings.ProwlarrUrl.TrimEnd('/')}/{indexer.Id}/api",
Apikey = _configFileProvider.ApiKey,
Categories = string.Join(",", indexerCapabilities.Categories.SupportedCategories(Settings.SyncCategories.ToArray())),

View file

@ -21,13 +21,15 @@ public class Radarr : ApplicationBase<RadarrSettings>
private readonly IRadarrV3Proxy _radarrV3Proxy;
private readonly ICached<List<RadarrIndexer>> _schemaCache;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Radarr(ICacheManager cacheManager, IRadarrV3Proxy radarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Radarr(ICacheManager cacheManager, IRadarrV3Proxy radarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_schemaCache = cacheManager.GetCache<List<RadarrIndexer>>(GetType());
_radarrV3Proxy = radarrV3Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -245,7 +247,7 @@ private RadarrIndexer BuildRadarrIndexer(IndexerDefinition indexer, IndexerCapab
var radarrIndexer = new RadarrIndexer
{
Id = id,
Name = $"{indexer.Name} (Prowlarr)",
Name = _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss,
EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch,
EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch,

View file

@ -21,13 +21,15 @@ public class Readarr : ApplicationBase<ReadarrSettings>
private readonly ICached<List<ReadarrIndexer>> _schemaCache;
private readonly IReadarrV1Proxy _readarrV1Proxy;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Readarr(ICacheManager cacheManager, IReadarrV1Proxy readarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Readarr(ICacheManager cacheManager, IReadarrV1Proxy readarrV1Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_schemaCache = cacheManager.GetCache<List<ReadarrIndexer>>(GetType());
_readarrV1Proxy = readarrV1Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -241,7 +243,7 @@ private ReadarrIndexer BuildReadarrIndexer(IndexerDefinition indexer, IndexerCap
var readarrIndexer = new ReadarrIndexer
{
Id = id,
Name = $"{indexer.Name} (Prowlarr)",
Name = _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss,
EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch,
EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch,

View file

@ -21,13 +21,15 @@ public class Sonarr : ApplicationBase<SonarrSettings>
private readonly ICached<List<SonarrIndexer>> _schemaCache;
private readonly ISonarrV3Proxy _sonarrV3Proxy;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Sonarr(ICacheManager cacheManager, ISonarrV3Proxy sonarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Sonarr(ICacheManager cacheManager, ISonarrV3Proxy sonarrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_schemaCache = cacheManager.GetCache<List<SonarrIndexer>>(GetType());
_sonarrV3Proxy = sonarrV3Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -253,7 +255,7 @@ private SonarrIndexer BuildSonarrIndexer(IndexerDefinition indexer, IndexerCapab
var sonarrIndexer = new SonarrIndexer
{
Id = id,
Name = $"{indexer.Name} (Prowlarr)",
Name = _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss,
EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch,
EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch,

View file

@ -21,13 +21,15 @@ public class Whisparr : ApplicationBase<WhisparrSettings>
private readonly IWhisparrV3Proxy _whisparrV3Proxy;
private readonly ICached<List<WhisparrIndexer>> _schemaCache;
private readonly IConfigFileProvider _configFileProvider;
private readonly IIndexerNameTemplateService _indexerNameTemplateService;
public Whisparr(ICacheManager cacheManager, IWhisparrV3Proxy whisparrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, Logger logger)
public Whisparr(ICacheManager cacheManager, IWhisparrV3Proxy whisparrV3Proxy, IConfigFileProvider configFileProvider, IAppIndexerMapService appIndexerMapService, IIndexerFactory indexerFactory, IIndexerNameTemplateService indexerNameTemplateService, Logger logger)
: base(appIndexerMapService, indexerFactory, logger)
{
_schemaCache = cacheManager.GetCache<List<WhisparrIndexer>>(GetType());
_whisparrV3Proxy = whisparrV3Proxy;
_configFileProvider = configFileProvider;
_indexerNameTemplateService = indexerNameTemplateService;
}
public override ValidationResult Test()
@ -232,7 +234,7 @@ private WhisparrIndexer BuildWhisparrIndexer(IndexerDefinition indexer, IndexerC
var whisparrIndexer = new WhisparrIndexer
{
Id = id,
Name = $"{indexer.Name} (Prowlarr)",
Name = _indexerNameTemplateService.FormatIndexerName(indexer.Name, _configFileProvider.InstanceName),
EnableRss = indexer.Enable && indexer.AppProfile.Value.EnableRss,
EnableAutomaticSearch = indexer.Enable && indexer.AppProfile.Value.EnableAutomaticSearch,
EnableInteractiveSearch = indexer.Enable && indexer.AppProfile.Value.EnableInteractiveSearch,

View file

@ -88,6 +88,12 @@ public bool LogIndexerResponse
set { SetValue("LogIndexerResponse", value); }
}
public string IndexerNameTemplate
{
get { return GetValue("indexernametemplate", Applications.IndexerNameTemplateDefaults.DefaultTemplate); }
set { SetValue("indexernametemplate", value); }
}
public int FirstDayOfWeek
{
get { return GetValueInt("FirstDayOfWeek", (int)CultureInfo.CurrentCulture.DateTimeFormat.FirstDayOfWeek); }

View file

@ -53,6 +53,7 @@ public interface IConfigService
// Indexers
bool LogIndexerResponse { get; set; }
string IndexerNameTemplate { get; set; }
CertificateValidationType CertificateValidation { get; }
string ApplicationUrl { get; }

View file

@ -384,6 +384,9 @@
"IndexerMTeamTpSettingsApiKeyHelpText": "API Key from the Site (Found in User Control Panel => Security => Laboratory)",
"IndexerMTeamTpSettingsFreeleechOnlyHelpText": "Search freeleech releases only",
"IndexerName": "Indexer Name",
"IndexerNameTemplate": "Indexer Name Template",
"IndexerNameTemplateHelpText": "Template to customize how indexer names appear in external applications. Use {name} for the indexer name and {instance} for the instance name. Default: {name} ({instance})",
"IndexerNameTemplateHelpTextWarning": "Template does not contain {name} - indexer names may not be distinguishable",
"IndexerNebulanceSettingsApiKeyHelpText": "API Key from User Settings > Api Keys. Key must have List and Download permissions",
"IndexerNewznabSettingsAdditionalParametersHelpText": "Additional Newznab parameters",
"IndexerNewznabSettingsApiKeyHelpText": "Site API Key",

View file

@ -47,6 +47,7 @@ public class HostConfigResource : RestResource
public int BackupRetention { get; set; }
public int HistoryCleanupDays { get; set; }
public bool TrustCgnatIpAddresses { get; set; }
public string IndexerNameTemplate { get; set; }
}
public static class HostConfigResourceMapper
@ -92,7 +93,8 @@ public static HostConfigResource ToResource(this IConfigFileProvider model, ICon
BackupInterval = configService.BackupInterval,
BackupRetention = configService.BackupRetention,
ApplicationUrl = configService.ApplicationUrl,
HistoryCleanupDays = configService.HistoryCleanupDays
HistoryCleanupDays = configService.HistoryCleanupDays,
IndexerNameTemplate = configService.IndexerNameTemplate
};
}
}