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:
TheOtherP 2021-04-11 13:39:59 +02:00
parent e71c65a4be
commit 8f3a04e720
22 changed files with 5116 additions and 3215 deletions

3
.gitignore vendored
View File

@ -17,4 +17,5 @@ pom.xml.versionsBackup
*.bak
*.phd
javacore*
heapdump*
heapdump*
/ui

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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");

View File

@ -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;
}
}

View File

@ -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());

View File

@ -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) {

View File

@ -64,4 +64,5 @@ public class SearchRequestFactory {
return request;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -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();
}
}

View 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>

View File

@ -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>

View File

@ -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"
}
});
}
}

View File

@ -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;

View File

@ -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);