mirror of
https://github.com/theotherp/nzbhydra2.git
synced 2026-02-06 11:17:18 +00:00
Custom mapping for queries and titles. This allows you to customize / change the values used by external tools or returned by metadata providers like TVDB.
Closes #700. Closes #638
This commit is contained in:
parent
e71c65a4be
commit
8f3a04e720
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,4 +17,5 @@ pom.xml.versionsBackup
|
||||
*.bak
|
||||
*.phd
|
||||
javacore*
|
||||
heapdump*
|
||||
heapdump*
|
||||
/ui
|
||||
|
||||
@ -21,6 +21,7 @@ import org.nzbhydra.mapping.newznab.xml.NewznabXmlError;
|
||||
import org.nzbhydra.mediainfo.Imdb;
|
||||
import org.nzbhydra.mediainfo.MediaIdType;
|
||||
import org.nzbhydra.searching.CategoryProvider;
|
||||
import org.nzbhydra.searching.CustomSearchRequestMapping;
|
||||
import org.nzbhydra.searching.SearchResult;
|
||||
import org.nzbhydra.searching.Searcher;
|
||||
import org.nzbhydra.searching.dtoseventsenums.DownloadType;
|
||||
@ -88,6 +89,8 @@ public class ExternalApi {
|
||||
private CapsGenerator capsGenerator;
|
||||
@Autowired
|
||||
private MockSearch mockSearch;
|
||||
@Autowired
|
||||
private CustomSearchRequestMapping customSearchRequestMapping;
|
||||
protected Clock clock = Clock.systemUTC();
|
||||
private final Random random = new Random();
|
||||
|
||||
@ -328,6 +331,7 @@ public class ExternalApi {
|
||||
searchRequest.getInternalData().setIncludePasswords(true);
|
||||
}
|
||||
searchRequest = searchRequestFactory.extendWithSavedIdentifiers(searchRequest);
|
||||
searchRequest = customSearchRequestMapping.mapSearchRequest(searchRequest);
|
||||
|
||||
return searchRequest;
|
||||
}
|
||||
|
||||
@ -6,12 +6,15 @@ import com.fasterxml.jackson.annotation.JsonSetter;
|
||||
import com.google.common.base.Strings;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.indexers.QueryGenerator;
|
||||
import org.nzbhydra.searching.CustomSearchRequestMapping;
|
||||
import org.nzbhydra.searching.searchrequests.SearchRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -29,6 +32,7 @@ public class SearchingConfig extends ValidatingConfig<SearchingConfig> {
|
||||
@JsonFormat(shape = Shape.STRING)
|
||||
private SearchSourceRestriction applyRestrictions = SearchSourceRestriction.BOTH;
|
||||
private int coverSize = 128;
|
||||
private List<CustomSearchRequestMapping.Mapping> customMappings = new ArrayList<>();
|
||||
private Integer globalCacheTimeMinutes;
|
||||
private float duplicateAgeThreshold = 2.0F;
|
||||
private float duplicateSizeThresholdInPercent = 1.0F;
|
||||
@ -111,6 +115,17 @@ public class SearchingConfig extends ValidatingConfig<SearchingConfig> {
|
||||
warnings.add("You selected not to apply any word restrictions in \"Searching\" but supplied a forbidden or required regex there");
|
||||
}
|
||||
}
|
||||
final CustomSearchRequestMapping customSearchRequestMapping = new CustomSearchRequestMapping();
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setTitle("test title");
|
||||
searchRequest.setQuery("test query");
|
||||
for (CustomSearchRequestMapping.Mapping customMapping : newConfig.getCustomMappings()) {
|
||||
try {
|
||||
customSearchRequestMapping.mapSearchRequest(searchRequest, Collections.singletonList(customMapping));
|
||||
} catch (Exception e) {
|
||||
errors.add(String.format("Unable to process mapping %s:}\n%s", customMapping.toString(), e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
return new ConfigValidationResult(errors.isEmpty(), false, errors, warnings);
|
||||
}
|
||||
|
||||
@ -348,7 +348,7 @@ public class NzbGet extends Downloader {
|
||||
if (nzbId <= 0) {
|
||||
throw new DownloaderException("NZBGet returned error code. Check its logs");
|
||||
}
|
||||
logger.info("Successfully added NZB \"{}\" to NZBGet queue with ID {} in category {}", title, nzbId, category);
|
||||
logger.info("Successfully added NZB \"{}\" to NZBGet queue with ID {} in category \"{}\"", title, nzbId, category);
|
||||
return String.valueOf(nzbId);
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import org.slf4j.MarkerFactory;
|
||||
public class LoggingMarkers {
|
||||
|
||||
public static final Marker CONFIG_READ_WRITE = MarkerFactory.getMarker("CONFIG_READ_WRITE");
|
||||
public static final Marker CUSTOM_MAPPING = MarkerFactory.getMarker("CUSTOM_MAPPING");
|
||||
public static final Marker DOWNLOADER_STATUS_UPDATE = MarkerFactory.getMarker("DOWNLOADER_STATUS_UPDATE");
|
||||
public static final Marker DOWNLOAD_STATUS_UPDATE = MarkerFactory.getMarker("DOWNLOAD_STATUS_UPDATE");
|
||||
public static final Marker DUPLICATES = MarkerFactory.getMarker("DUPLICATES");
|
||||
|
||||
@ -0,0 +1,246 @@
|
||||
/*
|
||||
* (C) Copyright 2021 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.nzbhydra.searching;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.google.common.base.Joiner;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import org.nzbhydra.config.ConfigProvider;
|
||||
import org.nzbhydra.logging.LoggingMarkers;
|
||||
import org.nzbhydra.searching.dtoseventsenums.SearchType;
|
||||
import org.nzbhydra.searching.searchrequests.SearchRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.security.access.annotation.Secured;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Component
|
||||
@RestController
|
||||
public class CustomSearchRequestMapping {
|
||||
|
||||
public enum AffectedValue {
|
||||
TITLE,
|
||||
QUERY
|
||||
}
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CustomSearchRequestMapping.class);
|
||||
|
||||
@Autowired
|
||||
private ConfigProvider configProvider;
|
||||
|
||||
|
||||
public SearchRequest mapSearchRequest(SearchRequest searchRequest) {
|
||||
return mapSearchRequest(searchRequest, configProvider.getBaseConfig().getSearching().getCustomMappings());
|
||||
}
|
||||
|
||||
public SearchRequest mapSearchRequest(SearchRequest searchRequest, List<Mapping> mappings) {
|
||||
final List<Mapping> datasets = mappings.stream()
|
||||
.filter(x -> searchRequest.getSearchType() == x.searchType)
|
||||
.filter(mapping -> isDatasetMatch(searchRequest, mapping))
|
||||
.filter(mapping -> {
|
||||
if (mapping.to.contains("{season") && !searchRequest.getSeason().isPresent()) {
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Can't use mapping {} because no season information is available for {}", mapping, searchRequest.simpleToString());
|
||||
return false;
|
||||
}
|
||||
if (mapping.to.contains("{episode") && !searchRequest.getEpisode().isPresent()) {
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Can't use mapping {} because no episode information is available for {}", mapping, searchRequest.simpleToString());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (datasets.isEmpty()) {
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "No datasets found matching: {}", searchRequest.simpleToString());
|
||||
return searchRequest;
|
||||
}
|
||||
if (datasets.size() > 1) {
|
||||
logger.error("Unable to map search request ({}) because multiple mappings match it:\n{}", searchRequest.simpleToString(), Joiner.on("\n").join(mappings));
|
||||
return searchRequest;
|
||||
}
|
||||
final Mapping mapping = datasets.get(0);
|
||||
if (mapping.affectedValue == AffectedValue.TITLE) {
|
||||
logger.debug("");
|
||||
}
|
||||
|
||||
mapSearchRequest(searchRequest, mapping);
|
||||
|
||||
return searchRequest;
|
||||
}
|
||||
|
||||
@SuppressWarnings("OptionalGetWithoutIsPresent")
|
||||
@Secured({"ROLE_ADMIN"})
|
||||
@RequestMapping(value = "/internalapi/customMapping/test", method = RequestMethod.POST, produces = MediaType.APPLICATION_JSON_VALUE)
|
||||
public TestResponse testMapping(@RequestBody TestRequest testRequest) {
|
||||
SearchRequest searchRequest = new SearchRequest();
|
||||
final String exampleInput = testRequest.exampleInput;
|
||||
if (!testRequest.mapping.getFromPattern().matcher(exampleInput).matches()) {
|
||||
return new TestResponse(null, null, false);
|
||||
}
|
||||
if (testRequest.mapping.affectedValue == AffectedValue.TITLE) {
|
||||
searchRequest.setTitle(exampleInput);
|
||||
} else {
|
||||
searchRequest.setQuery(exampleInput);
|
||||
}
|
||||
searchRequest.setSearchType(testRequest.mapping.searchType);
|
||||
searchRequest.setSeason(1);
|
||||
searchRequest.setEpisode("1");
|
||||
try {
|
||||
mapSearchRequest(searchRequest, testRequest.mapping);
|
||||
if (testRequest.mapping.affectedValue == AffectedValue.TITLE) {
|
||||
return new TestResponse(searchRequest.getTitle().get(), null, true);
|
||||
} else {
|
||||
return new TestResponse(searchRequest.getQuery().get(), null, true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return new TestResponse(null, e.getMessage(), false);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@SuppressWarnings("OptionalGetWithoutIsPresent")
|
||||
protected void mapSearchRequest(SearchRequest searchRequest, Mapping mapping) {
|
||||
//What should happen: q=Boku no Hero Academia S4, season=4, ep=21 -> Boku no Hero Academia s04e21
|
||||
//What the user should enter roughly: {0:(my hero academia|Boku no Hero Academia) {ignore:.*} -> {0} s{season:00} e{episode:00}
|
||||
//How it's configured: "TVSEARCH;QUERY;{0:(my hero academia|Boku no Hero Academia) {ignore:.*};{0} s{season:00} e{episode:00}"
|
||||
|
||||
//{title:the haunting} {0:.*} -> The Haunting of Bly Manor {0}
|
||||
|
||||
if (mapping.affectedValue == AffectedValue.QUERY) {
|
||||
final String newQuery = mapValue(searchRequest, mapping, searchRequest.getQuery().get());
|
||||
searchRequest.setQuery(newQuery);
|
||||
} else if (mapping.affectedValue == AffectedValue.TITLE) {
|
||||
final String newTitle = mapValue(searchRequest, mapping, searchRequest.getTitle().get());
|
||||
searchRequest.setTitle(newTitle);
|
||||
}
|
||||
}
|
||||
|
||||
private String mapValue(SearchRequest searchRequest, Mapping mapping, String value) {
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Mapping input \"{}\" using dataset \"{}\"", value, mapping);
|
||||
String mappedValue = value;
|
||||
|
||||
String replacementRegex = mapping.to;
|
||||
if (mapping.searchType == SearchType.TVSEARCH) {
|
||||
if (searchRequest.getSeason().isPresent()) {
|
||||
replacementRegex = replacementRegex.replace("{season:00}", String.format("%02d", searchRequest.getSeason().get()));
|
||||
replacementRegex = replacementRegex.replace("{season:0}", String.valueOf(searchRequest.getSeason().get()));
|
||||
}
|
||||
if (searchRequest.getEpisode().isPresent()) {
|
||||
try {
|
||||
Integer episode = Integer.parseInt(searchRequest.getEpisode().get());
|
||||
replacementRegex = replacementRegex.replace("{episode:00}", String.format("%02d", episode));
|
||||
replacementRegex = replacementRegex.replace("{episode:0}", episode.toString());
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
replacementRegex = replacementRegex.replace("{episode}", searchRequest.getEpisode().get());
|
||||
}
|
||||
}
|
||||
replacementRegex = replacementRegex.replaceAll("\\{(?<groupName>[^\\^}].*)}", "\\$\\{hydra${groupName}\\}");
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Mapping input \"{}\" using replacement regex \"{}\"", value, replacementRegex);
|
||||
mappedValue = mappedValue.replaceAll(mapping.getFromPattern().pattern(), replacementRegex);
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Mapped input \"{}\" to \"{}\"", value, mappedValue);
|
||||
return mappedValue;
|
||||
}
|
||||
|
||||
protected boolean isDatasetMatch(SearchRequest searchRequest, Mapping mapping) {
|
||||
if (mapping.affectedValue == AffectedValue.QUERY && searchRequest.getQuery().isPresent()) {
|
||||
final boolean matches = mapping.getFromPattern().matcher(searchRequest.getQuery().get()).matches();
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Query \"{}\" matches regex \"{}\": {}", searchRequest.getQuery().get(), mapping.getFromPattern().pattern(), matches);
|
||||
return matches;
|
||||
}
|
||||
if (mapping.affectedValue == AffectedValue.TITLE && searchRequest.getTitle().isPresent()) {
|
||||
final boolean matches = mapping.getFromPattern().matcher(searchRequest.getTitle().get()).matches();
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Title \"{}\" matches regex \"{}\": {}", searchRequest.getTitle().get(), mapping.getFromPattern().pattern(), matches);
|
||||
return matches;
|
||||
}
|
||||
logger.debug(LoggingMarkers.CUSTOM_MAPPING, "Dataset does not match search request.\nDataset: {}\nSearch request:{}", mapping.from, searchRequest.simpleToString());
|
||||
return false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Mapping {
|
||||
|
||||
private SearchType searchType;
|
||||
private AffectedValue affectedValue;
|
||||
private String from;
|
||||
private String to;
|
||||
@JsonIgnore
|
||||
private Pattern fromPattern;
|
||||
|
||||
public Mapping() {
|
||||
}
|
||||
|
||||
public Mapping(String configValue) {
|
||||
final String[] split = configValue.split(";");
|
||||
if (split.length != 4) {
|
||||
throw new IllegalArgumentException("Unable to parse value: " + configValue);
|
||||
}
|
||||
this.searchType = SearchType.valueOf(split[0].toUpperCase());
|
||||
this.affectedValue = AffectedValue.valueOf(split[1].toUpperCase());
|
||||
this.from = split[2];
|
||||
this.to = split[3];
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
public Pattern getFromPattern() {
|
||||
if (fromPattern == null) {
|
||||
String regex = from.replaceAll("\\{(?<groupName>[^:]*):(?<hydraContent>[^\\{\\}]*)\\}", "(?<hydra${groupName}>${hydraContent})");
|
||||
fromPattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE);
|
||||
}
|
||||
return fromPattern;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new StringJoiner(", ", Mapping.class.getSimpleName() + "[", "]")
|
||||
.add("from='" + from + "'")
|
||||
.add("to='" + to + "'")
|
||||
.toString();
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
static class TestRequest {
|
||||
private Mapping mapping;
|
||||
private String exampleInput;
|
||||
}
|
||||
|
||||
|
||||
@Data
|
||||
@AllArgsConstructor
|
||||
static class TestResponse {
|
||||
private final String output;
|
||||
private final String error;
|
||||
private boolean isMatch;
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -51,6 +51,8 @@ public class SearchWeb {
|
||||
private InternalSearchResultProcessor searchResultProcessor;
|
||||
@Autowired
|
||||
private SimpMessageSendingOperations messagingTemplate;
|
||||
@Autowired
|
||||
private CustomSearchRequestMapping customSearchRequestMapping;
|
||||
|
||||
private final Lock lock = new ReentrantLock();
|
||||
|
||||
@ -142,6 +144,7 @@ public class SearchWeb {
|
||||
}
|
||||
|
||||
searchRequest = searchRequestFactory.extendWithSavedIdentifiers(searchRequest);
|
||||
searchRequest = customSearchRequestMapping.mapSearchRequest(searchRequest);
|
||||
|
||||
//Initialize messages for this search request
|
||||
final SearchState searchState = new SearchState(searchRequest.getSearchRequestId());
|
||||
|
||||
@ -161,6 +161,20 @@ public class SearchRequest {
|
||||
.toString();
|
||||
}
|
||||
|
||||
public String simpleToString() {
|
||||
return MoreObjects.toStringHelper(this)
|
||||
.add("searchType", searchType)
|
||||
.add("category", category.getName())
|
||||
.add("query", query)
|
||||
.add("identifiers", identifiers)
|
||||
.add("title", title)
|
||||
.add("season", season)
|
||||
.add("episode", episode)
|
||||
.add("author", author)
|
||||
.omitNullValues()
|
||||
.toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
|
||||
@ -64,4 +64,5 @@ public class SearchRequestFactory {
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -336,6 +336,7 @@ searching:
|
||||
applyRestrictions: "NONE"
|
||||
coverSize: 128
|
||||
customQuickFilterButtons: []
|
||||
customQueryMappings: [ ]
|
||||
duplicateAgeThreshold: 2.0
|
||||
duplicateSizeThresholdInPercent: 1.0
|
||||
forbiddenGroups: []
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -5229,6 +5229,57 @@ angular
|
||||
wrapper: ['settingWrapper', 'bootstrapHasError']
|
||||
});
|
||||
|
||||
formlyConfigProvider.setType({
|
||||
name: 'customMappingTest',
|
||||
extends: 'horizontalInput',
|
||||
template: [
|
||||
'<div class="input-group">',
|
||||
'<button class="btn btn-default" type="button" ng-click="open()">Help and test</button>',
|
||||
'</div>'
|
||||
].join(' '),
|
||||
controller: function ($scope, $uibModal, $http) {
|
||||
var model = $scope.model;
|
||||
var modelCopy = Object.assign({}, model);
|
||||
$scope.open = function () {
|
||||
$uibModal.open({
|
||||
templateUrl: 'static/html/custom-mapping-help.html',
|
||||
controller: ["$scope", "$uibModalInstance", "$http", function ($scope, $uibModalInstance, $http) {
|
||||
$scope.model = modelCopy;
|
||||
$scope.cancel = function () {
|
||||
$uibModalInstance.close();
|
||||
}
|
||||
$scope.submit = function () {
|
||||
Object.assign(model, $scope.model)
|
||||
$uibModalInstance.close();
|
||||
}
|
||||
|
||||
$scope.test = function () {
|
||||
if (!$scope.exampleInput) {
|
||||
$scope.exampleResult = "Empty example data";
|
||||
return;
|
||||
|
||||
}
|
||||
$http.post('internalapi/customMapping/test', {mapping: model, exampleInput: $scope.exampleInput}).then(function (response) {
|
||||
console.log(response.data);
|
||||
console.log(response.data.output);
|
||||
if (response.data.error) {
|
||||
$scope.exampleResult = response.data.error;
|
||||
} else if (response.data.match) {
|
||||
$scope.exampleResult = response.data.output;
|
||||
} else {
|
||||
$scope.exampleResult = "Input does not match example";
|
||||
}
|
||||
}, function(response) {
|
||||
$scope.exampleResult = response.message;
|
||||
})
|
||||
}
|
||||
}],
|
||||
size: "md"
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateIndexerModel(model, indexerConfig) {
|
||||
model.supportedSearchIds = indexerConfig.supportedSearchIds;
|
||||
model.supportedSearchTypes = indexerConfig.supportedSearchTypes;
|
||||
@ -7062,6 +7113,78 @@ function ConfigFields($injector) {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'repeatSection',
|
||||
key: 'customMappings',
|
||||
model: rootModel.searching,
|
||||
templateOptions: {
|
||||
btnText: 'Add new custom mapping',
|
||||
altLegendText: 'Mapping',
|
||||
headline: 'Custom mappings of queries and titles',
|
||||
advanced: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'searchType',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
label: 'Search type',
|
||||
options: [
|
||||
{name: 'General', value: 'SEARCH'},
|
||||
{name: 'Audio', value: 'MUSIC'},
|
||||
{name: 'EBook', value: 'BOOK'},
|
||||
{name: 'Movie', value: 'MOVIE'},
|
||||
{name: 'TV', value: 'TVSEARCH'}
|
||||
],
|
||||
help: "Determines in what context the mapping will be executed"
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'affectedValue',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
label: 'Affected value',
|
||||
options: [
|
||||
{name: 'Query', value: 'QUERY'},
|
||||
{name: 'Title', value: 'TITLE'}
|
||||
],
|
||||
required: true,
|
||||
help: "Determines which value of the search request will be processed"
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'from',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Input pattern',
|
||||
help: 'Pattern which must match the query or title of a search request. You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',
|
||||
required: true
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Output pattern',
|
||||
help: 'If a query or title matches the input pattern it will be replaced using this. You may reference groups from the input pattern by using {group}. Additionally you may use {season:0} or {season:00} or {episode:0} or {episode:00} (with and without leading zeroes).',
|
||||
required: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'customMappingTest',
|
||||
}
|
||||
],
|
||||
defaultModel: {
|
||||
searchType: null,
|
||||
affectedValue: null,
|
||||
from: null,
|
||||
to: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
wrapper: 'fieldset',
|
||||
templateOptions: {
|
||||
@ -8043,6 +8166,7 @@ function handleConnectionCheckFail(ModalService, data, model, whatFailed, deferr
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
ConfigController.$inject = ["$scope", "$http", "activeTab", "ConfigService", "config", "DownloaderCategoriesService", "ConfigFields", "ConfigModel", "ModalService", "RestartService", "localStorageService", "$state", "growl", "$window"];angular
|
||||
.module('nzbhydraApp')
|
||||
.factory('ConfigModel', function () {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* (C) Copyright 2021 TheOtherP (theotherp@posteo.net)
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.nzbhydra.searching;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.nzbhydra.searching.searchrequests.SearchRequest;
|
||||
|
||||
import java.util.Collections;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
public class CustomSearchRequestMappingTest {
|
||||
|
||||
private final CustomSearchRequestMapping testee = new CustomSearchRequestMapping();
|
||||
|
||||
@Test
|
||||
public void shouldMapQueryForShowAddingSeasonAndEpisode() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setQuery("my show name s4");
|
||||
searchRequest.setSeason(4);
|
||||
searchRequest.setEpisode("21");
|
||||
|
||||
CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;QUERY;{title:my show name}{0:.*};{title} s{season:00}e{episode:00}");
|
||||
testee.mapSearchRequest(searchRequest, mapping);
|
||||
assertThat(searchRequest.getQuery()).isPresent().get().isEqualTo("my show name s04e21");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldSkipMappingOfMetagroupsIfDataUnavailable() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setQuery("my show name s4");
|
||||
|
||||
CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;QUERY;{title:my show name}{0:.*};{title} s{season:00}e{episode:00}");
|
||||
testee.mapSearchRequest(searchRequest, Collections.singletonList(mapping));
|
||||
assertThat(searchRequest.getQuery()).isPresent().get().isEqualTo("my show name s4");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapQueryForShowReplacingTheTitle() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setQuery("my show name s4");
|
||||
searchRequest.setSeason(4);
|
||||
searchRequest.setEpisode("21");
|
||||
|
||||
CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;QUERY;{title:my show name}{0:.*};some other title I want{0}");
|
||||
testee.mapSearchRequest(searchRequest, mapping);
|
||||
assertThat(searchRequest.getQuery()).isPresent().get().isEqualTo("some other title I want s4");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapQueryJustReplacingWithoutGroup() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setQuery("my show name s4");
|
||||
|
||||
CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;QUERY;my show name.*;some other title I want");
|
||||
testee.mapSearchRequest(searchRequest, mapping);
|
||||
assertThat(searchRequest.getQuery()).isPresent().get().isEqualTo("some other title I want");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldMapTitleForShowReplacingTheTitle() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setTitle("my show name");
|
||||
|
||||
CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;TITLE;{title:my show name};some other title I want");
|
||||
testee.mapSearchRequest(searchRequest, mapping);
|
||||
assertThat(searchRequest.getTitle()).isPresent().get().isEqualTo("some other title I want");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFindMatchingDatasetQuery() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
|
||||
searchRequest.setQuery("my show name s4");
|
||||
final CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;QUERY;{title:my show name}{0:.*};{title} s{season:00}e{episode:00}");
|
||||
assertThat(testee.isDatasetMatch(searchRequest, mapping)).isTrue();
|
||||
|
||||
searchRequest.setQuery("my show name whatever");
|
||||
assertThat(testee.isDatasetMatch(searchRequest, mapping)).isTrue();
|
||||
|
||||
searchRequest.setQuery("my other show name");
|
||||
assertThat(testee.isDatasetMatch(searchRequest, mapping)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldFindMatchingDatasetTitle() {
|
||||
final SearchRequest searchRequest = new SearchRequest();
|
||||
searchRequest.setTitle("my wrongly mapped title");
|
||||
final CustomSearchRequestMapping.Mapping mapping = new CustomSearchRequestMapping.Mapping("TVSEARCH;TITLE;{title:my wrongly mapped title};my correct title");
|
||||
assertThat(testee.isDatasetMatch(searchRequest, mapping)).isTrue();
|
||||
|
||||
searchRequest.setTitle("my correctly mapped title");
|
||||
assertThat(testee.isDatasetMatch(searchRequest, mapping)).isFalse();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
71
core/ui-src/html/custom-mapping-help.html
Normal file
71
core/ui-src/html/custom-mapping-help.html
Normal file
@ -0,0 +1,71 @@
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">Custom mapping help and test</h3>
|
||||
</div>
|
||||
<div class="modal-body" style="text-align: left">
|
||||
<div class="well">
|
||||
<ul>
|
||||
<li>
|
||||
The input must completely match the title or query for the mapping to be effective. The matching is case insensitive.
|
||||
</li>
|
||||
<li>
|
||||
You may use regular expressions anywhere (e.g. <code>[a-z]</code> or <code>.*</code>). You may use named groups to reference them in the output pattern (e.g. <code>{title:.*}</code> can be referenced using <code>{title}</code>.
|
||||
Brackets ("{}") may not be used in regexes.
|
||||
</li>
|
||||
<li>
|
||||
The following meta groups are available: <code>{season:0}</code>, <code>{season:00}</code>, <code>{episode:0}</code>, <code>{episode:00}</code> (with and without leading zeroes, respectively). The data will be taken from the search
|
||||
request's metadata. If it's not available the mapping will not be used.
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-5" style="text-align: right">
|
||||
<label>Input pattern</label>
|
||||
</div>
|
||||
<div class="col-md-15">
|
||||
<input type="text" class="form-control" ng-model="model.from">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-5" style="text-align: right">
|
||||
<label>Output pattern</label>
|
||||
</div>
|
||||
<div class="col-md-15">
|
||||
<input type="text" class="form-control" ng-model="model.to">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-5" style="text-align: right">
|
||||
<label>Example query/title</label>
|
||||
</div>
|
||||
<div class="col-md-15">
|
||||
<input type="text" class="form-control" ng-model="exampleInput">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-5" style="text-align: right">
|
||||
</div>
|
||||
<div class="col-md-15">
|
||||
<button class="btn btn-default" ng-click="test()">Test</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group row">
|
||||
<div class="col-md-5" style="text-align: right">
|
||||
<label>Result</label>
|
||||
</div>
|
||||
<div class="col-md-15">
|
||||
<input type="text" class="form-control" ng-model="exampleResult" disabled="disabled">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-warning" type="button" ng-click="cancel()">Cancel</button>
|
||||
<button class="btn btn-success" type="button" ng-click="submit()">Submit</button>
|
||||
</div>
|
||||
@ -100,7 +100,15 @@
|
||||
<script type="text/ng-template" id="repeatSection.html">
|
||||
<!--loop through each element in model array-->
|
||||
<div class="{{ hideRepeat }} repeatWrapperClass" ng-show="model.showAdvanced || !to.advanced">
|
||||
<legend><span class="config-fieldset-legend">{{to.headline}}</span></legend>
|
||||
<legend style="overflow: hidden">
|
||||
<span class="config-fieldset-legend">{{to.headline}}
|
||||
<span class="glyphicon glyphicon-question-sign" ng-if="::options.templateOptions.tooltip"
|
||||
uib-popover-html="options.templateOptions.tooltip"
|
||||
popover-trigger="'outsideClick'"
|
||||
style="font-size: 15px; cursor: pointer"></span>
|
||||
</span>
|
||||
</legend>
|
||||
|
||||
<div class="repeatsection modal-content" ng-repeat="element in model[options.key]"
|
||||
ng-init="fields = copyFields(to.fields)">
|
||||
<fieldset>
|
||||
@ -121,7 +129,7 @@
|
||||
|
||||
</fieldset>
|
||||
</div>
|
||||
<hr>
|
||||
<hr class="repeat-hr">
|
||||
<p class="addNewButton">
|
||||
<button type="button" class="btn btn-primary add-button" ng-click="addNew()">{{ to.btnText }}</button>
|
||||
</p>
|
||||
@ -225,4 +233,4 @@
|
||||
<span class="glyphicon glyphicon-eye-open"></span>
|
||||
</button>
|
||||
</span>
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@ -1237,6 +1237,78 @@ function ConfigFields($injector) {
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'repeatSection',
|
||||
key: 'customMappings',
|
||||
model: rootModel.searching,
|
||||
templateOptions: {
|
||||
btnText: 'Add new custom mapping',
|
||||
altLegendText: 'Mapping',
|
||||
headline: 'Custom mappings of queries and titles',
|
||||
advanced: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'searchType',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
label: 'Search type',
|
||||
options: [
|
||||
{name: 'General', value: 'SEARCH'},
|
||||
{name: 'Audio', value: 'MUSIC'},
|
||||
{name: 'EBook', value: 'BOOK'},
|
||||
{name: 'Movie', value: 'MOVIE'},
|
||||
{name: 'TV', value: 'TVSEARCH'}
|
||||
],
|
||||
help: "Determines in what context the mapping will be executed"
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'affectedValue',
|
||||
type: 'horizontalSelect',
|
||||
templateOptions: {
|
||||
label: 'Affected value',
|
||||
options: [
|
||||
{name: 'Query', value: 'QUERY'},
|
||||
{name: 'Title', value: 'TITLE'}
|
||||
],
|
||||
required: true,
|
||||
help: "Determines which value of the search request will be processed"
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'from',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Input pattern',
|
||||
help: 'Pattern which must match the query or title of a search request. You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',
|
||||
required: true
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'to',
|
||||
type: 'horizontalInput',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
label: 'Output pattern',
|
||||
help: 'If a query or title matches the input pattern it will be replaced using this. You may reference groups from the input pattern by using {group}. Additionally you may use {season:0} or {season:00} or {episode:0} or {episode:00} (with and without leading zeroes).',
|
||||
required: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'customMappingTest',
|
||||
}
|
||||
],
|
||||
defaultModel: {
|
||||
searchType: null,
|
||||
affectedValue: null,
|
||||
from: null,
|
||||
to: null
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
wrapper: 'fieldset',
|
||||
templateOptions: {
|
||||
@ -2216,4 +2288,4 @@ function handleConnectionCheckFail(ModalService, data, model, whatFailed, deferr
|
||||
text: "Aahh, let me try again"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -212,6 +212,57 @@ angular
|
||||
wrapper: ['settingWrapper', 'bootstrapHasError']
|
||||
});
|
||||
|
||||
formlyConfigProvider.setType({
|
||||
name: 'customMappingTest',
|
||||
extends: 'horizontalInput',
|
||||
template: [
|
||||
'<div class="input-group">',
|
||||
'<button class="btn btn-default" type="button" ng-click="open()">Help and test</button>',
|
||||
'</div>'
|
||||
].join(' '),
|
||||
controller: function ($scope, $uibModal, $http) {
|
||||
var model = $scope.model;
|
||||
var modelCopy = Object.assign({}, model);
|
||||
$scope.open = function () {
|
||||
$uibModal.open({
|
||||
templateUrl: 'static/html/custom-mapping-help.html',
|
||||
controller: function ($scope, $uibModalInstance, $http) {
|
||||
$scope.model = modelCopy;
|
||||
$scope.cancel = function () {
|
||||
$uibModalInstance.close();
|
||||
}
|
||||
$scope.submit = function () {
|
||||
Object.assign(model, $scope.model)
|
||||
$uibModalInstance.close();
|
||||
}
|
||||
|
||||
$scope.test = function () {
|
||||
if (!$scope.exampleInput) {
|
||||
$scope.exampleResult = "Empty example data";
|
||||
return;
|
||||
|
||||
}
|
||||
$http.post('internalapi/customMapping/test', {mapping: model, exampleInput: $scope.exampleInput}).then(function (response) {
|
||||
console.log(response.data);
|
||||
console.log(response.data.output);
|
||||
if (response.data.error) {
|
||||
$scope.exampleResult = response.data.error;
|
||||
} else if (response.data.match) {
|
||||
$scope.exampleResult = response.data.output;
|
||||
} else {
|
||||
$scope.exampleResult = "Input does not match example";
|
||||
}
|
||||
}, function (response) {
|
||||
$scope.exampleResult = response.message;
|
||||
})
|
||||
}
|
||||
},
|
||||
size: "md"
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateIndexerModel(model, indexerConfig) {
|
||||
model.supportedSearchIds = indexerConfig.supportedSearchIds;
|
||||
model.supportedSearchTypes = indexerConfig.supportedSearchTypes;
|
||||
|
||||
@ -48,6 +48,8 @@
|
||||
@btn-danger-bg: @brand-danger;
|
||||
@btn-danger-border: @btn-danger-bg;
|
||||
@btn-link-disabled-color: @gray-light;
|
||||
@code-bg: #868686;
|
||||
@code-color: #004414;
|
||||
@input-bg: rgb(31, 35, 40);
|
||||
@input-bg-disabled: @gray-lighter;
|
||||
@input-color: @text-color;
|
||||
@ -461,4 +463,3 @@ g.nv-pieChart > g > g.nv-legendWrap.nvd3-svg > g > g > g:nth-child(17) > circle
|
||||
.generate-week-colors(7);
|
||||
|
||||
.generate-day-colors(24);
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user