diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/IndexerRawSearchDefinitionBuilderFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/IndexerRawSearchDefinitionBuilderFixture.cs new file mode 100644 index 000000000..f16c67a99 --- /dev/null +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/IndexerRawSearchDefinitionBuilderFixture.cs @@ -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 + { + ["freeleech"] = true, + ["useFreeleechToken"] = 2, + ["someOtherField"] = "keep" + } + }, + ExtraFields = new List + { + 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 SearchTypes { get; set; } = Array.Empty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs index e67fda3f3..d286a8c7c 100644 --- a/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs +++ b/src/NzbDrone.Core.Test/IndexerSearchTests/ReleaseSearchServiceFixture.cs @@ -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() + .Setup(s => s.GetInstance(It.IsAny())) + .Callback(definition => rawDefinition = definition) + .Returns(_mockIndexer.Object); + + var request = new NewznabRequest + { + t = "movie", + searchMode = "raw" + }; + + Subject.Search(request, new List { 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); + } } } diff --git a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs index 15c4aa35b..76a239533 100644 --- a/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs +++ b/src/NzbDrone.Core/IndexerSearch/Definitions/SearchCriteriaBase.cs @@ -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)}]"; diff --git a/src/NzbDrone.Core/IndexerSearch/IndexerRawSearchDefinitionBuilder.cs b/src/NzbDrone.Core/IndexerSearch/IndexerRawSearchDefinitionBuilder.cs new file mode 100644 index 000000000..3033298f0 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/IndexerRawSearchDefinitionBuilder.cs @@ -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(definition.Tags) : new HashSet(), + 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() + }; + } + + 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(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(); + 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 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; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/IndexerSearchMode.cs b/src/NzbDrone.Core/IndexerSearch/IndexerSearchMode.cs new file mode 100644 index 000000000..7be554743 --- /dev/null +++ b/src/NzbDrone.Core/IndexerSearch/IndexerSearchMode.cs @@ -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 + }; + } + } +} diff --git a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs index 63ade87b5..747aa7e25 100644 --- a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs +++ b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs @@ -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() { diff --git a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs index 4d5fbd5d3..26e4b7214 100644 --- a/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs +++ b/src/NzbDrone.Core/IndexerSearch/ReleaseSearchService.cs @@ -150,6 +150,7 @@ private TSpec Get(NewznabRequest query, List 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> Dispatch(Func 0 }) { // Only query supported indexers @@ -201,6 +207,17 @@ private async Task> Dispatch(Func> DispatchIndexer(Func> searchAction, IIndexer indexer, SearchCriteriaBase criteriaBase) { if (_indexerLimitService.AtQueryLimit((IndexerDefinition)indexer.Definition)) diff --git a/src/NzbDrone.Core/Indexers/Definitions/BeyondHD.cs b/src/NzbDrone.Core/Indexers/Definitions/BeyondHD.cs index 32de3f977..95a363b43 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/BeyondHD.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/BeyondHD.cs @@ -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 SearchTypes { get; set; } diff --git a/src/NzbDrone.Core/Indexers/Definitions/TorrentSyndikat.cs b/src/NzbDrone.Core/Indexers/Definitions/TorrentSyndikat.cs index a986c7291..24ead0040 100644 --- a/src/NzbDrone.Core/Indexers/Definitions/TorrentSyndikat.cs +++ b/src/NzbDrone.Core/Indexers/Definitions/TorrentSyndikat.cs @@ -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 ReleaseTypes { get; set; } diff --git a/src/NzbDrone.Core/Indexers/SearchConstraintAttribute.cs b/src/NzbDrone.Core/Indexers/SearchConstraintAttribute.cs new file mode 100644 index 000000000..9234442a0 --- /dev/null +++ b/src/NzbDrone.Core/Indexers/SearchConstraintAttribute.cs @@ -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 + } +} diff --git a/src/Prowlarr.Api.V1.Test/Indexers/NewznabControllerFixture.cs b/src/Prowlarr.Api.V1.Test/Indexers/NewznabControllerFixture.cs new file mode 100644 index 000000000..34974476c --- /dev/null +++ b/src/Prowlarr.Api.V1.Test/Indexers/NewznabControllerFixture.cs @@ -0,0 +1,18 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using NUnit.Framework; +using NzbDrone.Api.V1.Indexers; + +namespace Prowlarr.Api.V1.Test.Indexers +{ + public class NewznabControllerFixture + { + [TestCase("/api/v1/indexer/12/newznab", true)] + [TestCase("/12/api", false)] + [TestCase("/api/v1/indexer/12/download", false)] + public void should_only_allow_extended_search_parameters_on_prowlarr_newznab_route(string path, bool expected) + { + NewznabController.SupportsExtendedSearchParameters(new PathString(path)).Should().Be(expected); + } + } +} diff --git a/src/Prowlarr.Api.V1.Test/Prowlarr.Api.V1.Test.csproj b/src/Prowlarr.Api.V1.Test/Prowlarr.Api.V1.Test.csproj index eec930286..72a865158 100644 --- a/src/Prowlarr.Api.V1.Test/Prowlarr.Api.V1.Test.csproj +++ b/src/Prowlarr.Api.V1.Test/Prowlarr.Api.V1.Test.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Prowlarr.Api.V1/AssemblyInfo.cs b/src/Prowlarr.Api.V1/AssemblyInfo.cs new file mode 100644 index 000000000..59dc537b5 --- /dev/null +++ b/src/Prowlarr.Api.V1/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Prowlarr.Api.V1.Test")] diff --git a/src/Prowlarr.Api.V1/Indexers/NewznabController.cs b/src/Prowlarr.Api.V1/Indexers/NewznabController.cs index cde49b789..82b0b1353 100644 --- a/src/Prowlarr.Api.V1/Indexers/NewznabController.cs +++ b/src/Prowlarr.Api.V1/Indexers/NewznabController.cs @@ -58,6 +58,11 @@ public NewznabController(IndexerFactory indexerFactory, [HttpGet("{id:int}/api")] public async Task GetNewznabResponse(int id, [FromQuery] NewznabRequest request) { + if (!SupportsExtendedSearchParameters(Request.Path)) + { + request.searchMode = null; + } + var requestType = request.t; request.source = Request.GetSource(); request.server = Request.GetServerUrl(); @@ -206,6 +211,12 @@ public async Task GetNewznabResponse(int id, [FromQuery] NewznabR } } + internal static bool SupportsExtendedSearchParameters(PathString path) + { + return path.Value?.StartsWith("/api/v1/indexer/", StringComparison.OrdinalIgnoreCase) == true && + path.Value.EndsWith("/newznab", StringComparison.OrdinalIgnoreCase); + } + [HttpGet("/api/v1/indexer/{id:int}/download")] [HttpGet("{id:int}/download")] public async Task GetDownload(int id, string link, string file) diff --git a/src/Prowlarr.Api.V1/Search/SearchController.cs b/src/Prowlarr.Api.V1/Search/SearchController.cs index c73ae00f3..123ed1dc7 100644 --- a/src/Prowlarr.Api.V1/Search/SearchController.cs +++ b/src/Prowlarr.Api.V1/Search/SearchController.cs @@ -163,6 +163,7 @@ private async Task> GetSearchReleases([FromQuery] SearchRe cat = string.Join(",", payload.Categories), server = Request.GetServerUrl(), host = Request.GetHostName(), + searchMode = payload.SearchMode, limit = payload.Limit, offset = payload.Offset }; diff --git a/src/Prowlarr.Api.V1/Search/SearchResource.cs b/src/Prowlarr.Api.V1/Search/SearchResource.cs index 2c6531ade..ecdbedee7 100644 --- a/src/Prowlarr.Api.V1/Search/SearchResource.cs +++ b/src/Prowlarr.Api.V1/Search/SearchResource.cs @@ -12,6 +12,7 @@ public SearchResource() public string Query { get; set; } public string Type { get; set; } + public string SearchMode { get; set; } public List IndexerIds { get; set; } public List Categories { get; set; } public int? Limit { get; set; }