Add raw search mode for API indexer lookups

This commit is contained in:
Tomer Horowitz 2026-03-07 21:00:12 +02:00
parent 5858c2dda6
commit a7e44d15df
12 changed files with 472 additions and 0 deletions

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using FluentAssertions;
using NUnit.Framework;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.Indexers.Definitions.Cardigann;
using NzbDrone.Core.Indexers.Settings;
namespace NzbDrone.Core.Test.IndexerSearchTests
{
public class IndexerRawSearchDefinitionBuilderFixture
{
[Test]
public void should_reset_known_search_constraints_on_cloned_settings()
{
var original = new IndexerDefinition
{
Id = 1,
Name = "test",
Implementation = "TestIndexer",
Settings = new TestIndexerSettings
{
BaseUrl = "https://tracker.example",
FreeleechOnly = true,
UseFreeleechToken = 2,
LimitedOnly = true,
SearchTypes = new[] { 1, 2 },
BaseSettings = new IndexerBaseSettings { QueryLimit = 42 }
}
};
var clone = IndexerRawSearchDefinitionBuilder.Build(original);
var cloneSettings = (TestIndexerSettings)clone.Settings;
var originalSettings = (TestIndexerSettings)original.Settings;
cloneSettings.FreeleechOnly.Should().BeFalse();
cloneSettings.UseFreeleechToken.Should().Be(0);
cloneSettings.LimitedOnly.Should().BeFalse();
cloneSettings.SearchTypes.Should().BeEmpty();
cloneSettings.BaseSettings.QueryLimit.Should().Be(42);
originalSettings.FreeleechOnly.Should().BeTrue();
originalSettings.UseFreeleechToken.Should().Be(2);
originalSettings.LimitedOnly.Should().BeTrue();
originalSettings.SearchTypes.Should().Equal(1, 2);
}
[Test]
public void should_reset_known_cardigann_search_constraint_fields()
{
var original = new IndexerDefinition
{
Id = 2,
Name = "cardigann-test",
Implementation = "Cardigann",
Settings = new CardigannSettings
{
DefinitionFile = "test.yml",
BaseUrl = "https://tracker.example",
ExtraFieldData = new Dictionary<string, object>
{
["freeleech"] = true,
["useFreeleechToken"] = 2,
["someOtherField"] = "keep"
}
},
ExtraFields = new List<SettingsField>
{
new() { Name = "freeleech", Type = "checkbox", Label = "Freeleech only", Default = "false" },
new() { Name = "useFreeleechToken", Type = "select", Label = "Use freeleech token", Default = "0" },
new() { Name = "someOtherField", Type = "text", Label = "Other field" }
}
};
var clone = IndexerRawSearchDefinitionBuilder.Build(original);
var cloneSettings = (CardigannSettings)clone.Settings;
var originalSettings = (CardigannSettings)original.Settings;
cloneSettings.ExtraFieldData["freeleech"].Should().Be(false);
cloneSettings.ExtraFieldData["useFreeleechToken"].Should().Be(0);
cloneSettings.ExtraFieldData["someOtherField"].Should().Be("keep");
originalSettings.ExtraFieldData["freeleech"].Should().Be(true);
originalSettings.ExtraFieldData["useFreeleechToken"].Should().Be(2);
}
private class TestIndexerSettings : NoAuthTorrentBaseSettings
{
public bool FreeleechOnly { get; set; }
public int UseFreeleechToken { get; set; }
[SearchConstraint(SearchConstraintResetBehavior.False)]
public bool LimitedOnly { get; set; }
[SearchConstraint]
public IEnumerable<int> SearchTypes { get; set; } = Array.Empty<int>();
}
}
}

View file

@ -5,6 +5,7 @@
using Moq;
using NUnit.Framework;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Definitions;
using NzbDrone.Core.IndexerSearch;
using NzbDrone.Core.IndexerSearch.Definitions;
using NzbDrone.Core.Test.Framework;
@ -87,5 +88,61 @@ public void should_normalize_imdbid_tv_search_criteria(string input, string expe
criteria.Count.Should().Be(1);
criteria[0].ImdbId.Should().Be(expected);
}
[Test]
public void should_clone_indexer_definition_for_raw_search_mode()
{
var originalDefinition = new IndexerDefinition
{
Id = 1,
Name = "BeyondHD",
Implementation = nameof(BeyondHD),
Settings = new BeyondHDSettings
{
BaseUrl = "https://tracker.example",
FreeleechOnly = true,
LimitedOnly = true,
RefundOnly = true,
RewindOnly = true,
SearchTypes = new[] { (int)BeyondHDSearchType.TypeUhd100 }
}
};
_mockIndexer.SetupGet(s => s.Definition).Returns(originalDefinition);
IndexerDefinition rawDefinition = null;
Mocker.GetMock<IIndexerFactory>()
.Setup(s => s.GetInstance(It.IsAny<IndexerDefinition>()))
.Callback<IndexerDefinition>(definition => rawDefinition = definition)
.Returns(_mockIndexer.Object);
var request = new NewznabRequest
{
t = "movie",
searchMode = "raw"
};
Subject.Search(request, new List<int> { 1 }, false).GetAwaiter().GetResult();
rawDefinition.Should().NotBeNull();
rawDefinition.Should().NotBeSameAs(originalDefinition);
rawDefinition.Settings.Should().NotBeSameAs(originalDefinition.Settings);
var rawSettings = (BeyondHDSettings)rawDefinition.Settings;
var originalSettings = (BeyondHDSettings)originalDefinition.Settings;
rawSettings.FreeleechOnly.Should().BeFalse();
rawSettings.LimitedOnly.Should().BeFalse();
rawSettings.RefundOnly.Should().BeFalse();
rawSettings.RewindOnly.Should().BeFalse();
rawSettings.SearchTypes.Should().BeEmpty();
originalSettings.FreeleechOnly.Should().BeTrue();
originalSettings.LimitedOnly.Should().BeTrue();
originalSettings.RefundOnly.Should().BeTrue();
originalSettings.RewindOnly.Should().BeTrue();
originalSettings.SearchTypes.Should().ContainSingle().Which.Should().Be((int)BeyondHDSearchType.TypeUhd100);
}
}
}

View file

@ -23,6 +23,7 @@ public abstract class SearchCriteriaBase
public long? MaxSize { get; set; }
public string Source { get; set; }
public string Host { get; set; }
public IndexerSearchMode SearchMode { get; set; }
public override string ToString() => $"{SearchQuery}, Offset: {Offset ?? 0}, Limit: {Limit ?? 0}, Categories: [{string.Join(", ", Categories)}]";

View file

@ -0,0 +1,239 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.Indexers;
using NzbDrone.Core.Indexers.Definitions.Cardigann;
using NzbDrone.Core.ThingiProvider;
namespace NzbDrone.Core.IndexerSearch
{
internal static class IndexerRawSearchDefinitionBuilder
{
public static IndexerDefinition Build(IndexerDefinition definition)
{
if (definition?.Settings == null)
{
return definition;
}
var clonedSettings = CloneSettings(definition.Settings);
ApplyConstraintOverrides(clonedSettings);
var clonedDefinition = CloneDefinition(definition, clonedSettings);
if (clonedSettings is CardigannSettings cardigannSettings)
{
ApplyCardigannOverrides(cardigannSettings, clonedDefinition.ExtraFields);
}
return clonedDefinition;
}
private static IProviderConfig CloneSettings(IProviderConfig settings)
{
var serialized = JsonConvert.SerializeObject(settings);
return (IProviderConfig)JsonConvert.DeserializeObject(serialized, settings.GetType());
}
private static IndexerDefinition CloneDefinition(IndexerDefinition definition, IProviderConfig settings)
{
return new IndexerDefinition
{
Id = definition.Id,
Name = definition.Name,
ImplementationName = definition.ImplementationName,
Implementation = definition.Implementation,
ConfigContract = definition.ConfigContract,
Enable = definition.Enable,
Message = definition.Message,
Tags = definition.Tags != null ? new HashSet<int>(definition.Tags) : new HashSet<int>(),
Settings = settings,
IndexerUrls = definition.IndexerUrls,
LegacyUrls = definition.LegacyUrls,
Description = definition.Description,
Encoding = definition.Encoding,
Language = definition.Language,
Protocol = definition.Protocol,
Privacy = definition.Privacy,
SupportsRss = definition.SupportsRss,
SupportsSearch = definition.SupportsSearch,
SupportsRedirect = definition.SupportsRedirect,
SupportsPagination = definition.SupportsPagination,
Capabilities = definition.Capabilities,
Priority = definition.Priority,
Redirect = definition.Redirect,
DownloadClientId = definition.DownloadClientId,
Added = definition.Added,
AppProfileId = definition.AppProfileId,
AppProfile = definition.AppProfile,
ExtraFields = definition.ExtraFields?.Select(CloneExtraField).ToList() ?? new List<SettingsField>()
};
}
private static SettingsField CloneExtraField(SettingsField field)
{
return new SettingsField
{
Name = field.Name,
Type = field.Type,
Label = field.Label,
Default = field.Default,
Defaults = field.Defaults?.ToArray(),
Options = field.Options != null ? new Dictionary<string, string>(field.Options) : null
};
}
private static void ApplyConstraintOverrides(object target)
{
if (target == null)
{
return;
}
var targetType = target.GetType();
object defaults = null;
try
{
defaults = Activator.CreateInstance(targetType);
}
catch
{
// Best-effort only. If a type cannot be constructed we can still apply explicit false/null resets.
}
foreach (var property in targetType.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!property.CanRead || !property.CanWrite || property.GetIndexParameters().Length > 0)
{
continue;
}
var currentValue = property.GetValue(target);
var defaultValue = defaults != null ? property.GetValue(defaults) : null;
if (TryGetResetBehavior(property, out var resetBehavior))
{
property.SetValue(target, BuildResetValue(property.PropertyType, resetBehavior, defaultValue));
continue;
}
if (ShouldRecurse(property.PropertyType, currentValue))
{
ApplyConstraintOverrides(currentValue);
}
}
}
private static bool TryGetResetBehavior(PropertyInfo property, out SearchConstraintResetBehavior resetBehavior)
{
var explicitAttribute = property.GetCustomAttribute<SearchConstraintAttribute>();
if (explicitAttribute != null)
{
resetBehavior = explicitAttribute.ResetBehavior;
return true;
}
if (property.Name.EndsWith("Only", StringComparison.OrdinalIgnoreCase))
{
resetBehavior = property.PropertyType == typeof(bool) || property.PropertyType == typeof(bool?)
? SearchConstraintResetBehavior.False
: SearchConstraintResetBehavior.Default;
return true;
}
if (property.Name.Contains("UseFreeleechToken", StringComparison.OrdinalIgnoreCase))
{
resetBehavior = SearchConstraintResetBehavior.Default;
return true;
}
resetBehavior = default;
return false;
}
private static object BuildResetValue(Type propertyType, SearchConstraintResetBehavior resetBehavior, object defaultValue)
{
return resetBehavior switch
{
SearchConstraintResetBehavior.False when propertyType == typeof(bool?) => (bool?)false,
SearchConstraintResetBehavior.False => false,
SearchConstraintResetBehavior.Empty => string.Empty,
SearchConstraintResetBehavior.Null => null,
_ => defaultValue ?? (propertyType.IsValueType ? Activator.CreateInstance(propertyType) : null)
};
}
private static bool ShouldRecurse(Type propertyType, object currentValue)
{
if (currentValue == null || propertyType == typeof(string))
{
return false;
}
if (typeof(IDictionary).IsAssignableFrom(propertyType) || typeof(IEnumerable).IsAssignableFrom(propertyType))
{
return false;
}
return propertyType.IsClass;
}
private static void ApplyCardigannOverrides(CardigannSettings settings, List<SettingsField> extraFields)
{
if (settings.ExtraFieldData == null || extraFields == null)
{
return;
}
foreach (var field in extraFields)
{
if (!ShouldResetCardigannField(field))
{
continue;
}
settings.ExtraFieldData[field.Name] = GetCardigannResetValue(field);
}
}
private static bool ShouldResetCardigannField(SettingsField field)
{
var combined = $"{field.Name} {field.Label}".ToLowerInvariant();
return combined.Contains("freeleech") ||
combined.Contains("freeload") ||
combined.Contains("limited") ||
(combined.Contains("token") && combined.Contains("free"));
}
private static object GetCardigannResetValue(SettingsField field)
{
if (string.Equals(field.Type, "checkbox", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (field.Default.IsNotNullOrWhiteSpace())
{
if (int.TryParse(field.Default, out var number))
{
return number;
}
if (bool.TryParse(field.Default, out var boolean))
{
return boolean;
}
return field.Default;
}
return string.Equals(field.Type, "select", StringComparison.OrdinalIgnoreCase) ? string.Empty : null;
}
}
}

View file

@ -0,0 +1,29 @@
using NzbDrone.Common.Extensions;
namespace NzbDrone.Core.IndexerSearch
{
public enum IndexerSearchMode
{
Default = 0,
Raw = 1
}
public static class IndexerSearchModeParser
{
public static IndexerSearchMode Parse(string value)
{
if (value.IsNullOrWhiteSpace())
{
return IndexerSearchMode.Default;
}
return value.Trim().ToLowerInvariant() switch
{
"raw" => IndexerSearchMode.Raw,
"normal" => IndexerSearchMode.Default,
"default" => IndexerSearchMode.Default,
_ => IndexerSearchMode.Default
};
}
}
}

View file

@ -42,6 +42,7 @@ public class NewznabRequest
public string source { get; set; }
public string host { get; set; }
public string server { get; set; }
public string searchMode { get; set; }
public void QueryToParams()
{

View file

@ -150,6 +150,7 @@ private TSpec Get<TSpec>(NewznabRequest query, List<int> indexerIds, bool intera
spec.MaxSize = query.maxsize;
spec.Source = query.source;
spec.Host = query.host;
spec.SearchMode = IndexerSearchModeParser.Parse(query.searchMode);
spec.IndexerIds = indexerIds;
@ -175,6 +176,11 @@ private async Task<IList<ReleaseInfo>> Dispatch(Func<IIndexer, Task<IndexerPagea
}
}
if (criteriaBase.SearchMode == IndexerSearchMode.Raw)
{
indexers = indexers.Select(CreateRawSearchIndexer).ToList();
}
if (criteriaBase.Categories is { Length: > 0 })
{
// Only query supported indexers
@ -201,6 +207,17 @@ private async Task<IList<ReleaseInfo>> Dispatch(Func<IIndexer, Task<IndexerPagea
return reports;
}
private IIndexer CreateRawSearchIndexer(IIndexer indexer)
{
if (indexer.Definition is not IndexerDefinition definition)
{
return indexer;
}
var rawDefinition = IndexerRawSearchDefinitionBuilder.Build(definition);
return _indexerFactory.GetInstance(rawDefinition);
}
private async Task<IList<ReleaseInfo>> DispatchIndexer(Func<IIndexer, Task<IndexerPageableQueryResult>> searchAction, IIndexer indexer, SearchCriteriaBase criteriaBase)
{
if (_indexerLimitService.AtQueryLimit((IndexerDefinition)indexer.Definition))

View file

@ -389,6 +389,7 @@ public BeyondHDSettings()
[FieldDefinition(7, Label = "IndexerBeyondHDSettingsRewindOnly", Type = FieldType.Checkbox, HelpText = "IndexerBeyondHDSettingsRewindOnlyHelpText")]
public bool RewindOnly { get; set; }
[SearchConstraint]
[FieldDefinition(8, Label = "IndexerBeyondHDSettingsSearchTypes", Type = FieldType.Select, SelectOptions = typeof(BeyondHDSearchType), HelpText = "IndexerBeyondHDSettingsSearchTypesHelpText", Advanced = true)]
public IEnumerable<int> SearchTypes { get; set; }

View file

@ -342,6 +342,7 @@ public TorrentSyndikatSettings()
[FieldDefinition(3, Label = "Products Only", Type = FieldType.Checkbox, HelpText = "Limit search to torrents linked to a product")]
public bool ProductsOnly { get; set; }
[SearchConstraint]
[FieldDefinition(4, Label = "Release Types", Type = FieldType.Select, SelectOptions = typeof(TorrentSyndikatReleaseTypes))]
public IEnumerable<int> ReleaseTypes { get; set; }

View file

@ -0,0 +1,23 @@
using System;
namespace NzbDrone.Core.Indexers
{
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class SearchConstraintAttribute : Attribute
{
public SearchConstraintAttribute(SearchConstraintResetBehavior resetBehavior = SearchConstraintResetBehavior.Default)
{
ResetBehavior = resetBehavior;
}
public SearchConstraintResetBehavior ResetBehavior { get; }
}
public enum SearchConstraintResetBehavior
{
Default,
False,
Empty,
Null
}
}

View file

@ -163,6 +163,7 @@ private async Task<List<ReleaseResource>> GetSearchReleases([FromQuery] SearchRe
cat = string.Join(",", payload.Categories),
server = Request.GetServerUrl(),
host = Request.GetHostName(),
searchMode = payload.SearchMode,
limit = payload.Limit,
offset = payload.Offset
};

View file

@ -12,6 +12,7 @@ public SearchResource()
public string Query { get; set; }
public string Type { get; set; }
public string SearchMode { get; set; }
public List<int> IndexerIds { get; set; }
public List<int> Categories { get; set; }
public int Limit { get; set; }