mirror of
https://github.com/theotherp/nzbhydra2.git
synced 2026-02-06 11:17:18 +00:00
parent
a32dc300f6
commit
0fa5b1ddc6
@ -34,7 +34,10 @@
|
||||
"Bash(python3:*)",
|
||||
"Bash(/c/Programme/python312/python:*)",
|
||||
"mcp__jetbrains__get_run_configurations",
|
||||
"mcp__jetbrains__execute_run_configuration"
|
||||
"mcp__jetbrains__execute_run_configuration",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"WebFetch(domain:api.github.com)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@ -64,6 +64,12 @@ public class IndexerConfigValidator implements ConfigValidator<IndexerConfig> {
|
||||
}
|
||||
});
|
||||
|
||||
newConfig.getAttributeWhitelist().forEach(x -> {
|
||||
if (Strings.isNullOrEmpty(x) || StringUtils.countMatches(x, '=') != 1) {
|
||||
validationResult.getErrorMessages().add("The attribute whitelist entry '" + x + "' is invalid. You must use the format name=value or name=value1,value2.");
|
||||
}
|
||||
});
|
||||
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
|
||||
@ -100,6 +100,9 @@ public class SearchResultAcceptor {
|
||||
if (!checkMinSeeders(indexerConfig, reasonsForRejection, item)) {
|
||||
continue;
|
||||
}
|
||||
if (!checkAttributeWhitelist(indexerConfig, reasonsForRejection, item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
//Forbidden words from query
|
||||
if (!checkForForbiddenWords(indexerConfig, reasonsForRejection, searchRequest.getInternalData().getForbiddenWords(), item, "internal data")) {
|
||||
@ -396,6 +399,98 @@ public class SearchResultAcceptor {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an item matches the configured attribute whitelist for the indexer.
|
||||
* <p>
|
||||
* The whitelist uses OR logic between different entries (e.g., ["subs=English", "subs=French"] accepts if EITHER matches).
|
||||
* Comma-separated values within one entry use AND logic (e.g., "subs=English,French" requires BOTH to be present).
|
||||
* Items without any attributes are rejected when a whitelist is configured.
|
||||
* <p>
|
||||
* The whitelist can be limited to specific categories. If no categories are configured, the whitelist applies to all results.
|
||||
*
|
||||
* @param indexerConfig the indexer configuration containing the whitelist
|
||||
* @param reasonsForRejection multiset to track rejection reasons
|
||||
* @param item the search result item to check
|
||||
* @return true if the item should be accepted, false if it should be rejected
|
||||
*/
|
||||
protected boolean checkAttributeWhitelist(IndexerConfig indexerConfig, Multiset<String> reasonsForRejection, SearchResultItem item) {
|
||||
List<String> whitelist = indexerConfig.getAttributeWhitelist();
|
||||
if (whitelist == null || whitelist.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the whitelist should apply to this item's category
|
||||
List<String> whitelistCategories = indexerConfig.getAttributeWhitelistCategories();
|
||||
if (whitelistCategories != null && !whitelistCategories.isEmpty()) {
|
||||
String itemCategoryName = item.getCategory() != null ? item.getCategory().getName() : null;
|
||||
if (itemCategoryName == null || !whitelistCategories.contains(itemCategoryName)) {
|
||||
// Category not in the whitelist categories - skip attribute filtering for this item
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String> itemAttributes = item.getAttributes();
|
||||
if (itemAttributes == null || itemAttributes.isEmpty()) {
|
||||
logger.debug(LoggingMarkers.RESULT_ACCEPTOR, "Rejecting '{}' because it has no attributes but attribute whitelist is configured", item.getTitle());
|
||||
reasonsForRejection.add("No attributes but whitelist configured");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each whitelist entry - OR logic between entries
|
||||
for (String whitelistEntry : whitelist) {
|
||||
if (matchesWhitelistEntry(whitelistEntry, itemAttributes)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(LoggingMarkers.RESULT_ACCEPTOR, "Rejecting '{}' because it doesn't match any attribute whitelist entry", item.getTitle());
|
||||
reasonsForRejection.add("Attribute whitelist not matched");
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if item attributes match a single whitelist entry.
|
||||
* For comma-separated values, ALL values must be present (AND logic).
|
||||
*/
|
||||
private boolean matchesWhitelistEntry(String whitelistEntry, Map<String, String> itemAttributes) {
|
||||
int equalsIndex = whitelistEntry.indexOf('=');
|
||||
if (equalsIndex <= 0 || equalsIndex >= whitelistEntry.length() - 1) {
|
||||
logger.error("Invalid whitelist entry format: '{}'", whitelistEntry);
|
||||
return false;
|
||||
}
|
||||
|
||||
String attributeName = whitelistEntry.substring(0, equalsIndex).trim().toLowerCase();
|
||||
String requiredValuesStr = whitelistEntry.substring(equalsIndex + 1).trim();
|
||||
|
||||
// Get the item's value for this attribute (case-insensitive key lookup)
|
||||
String itemValue = null;
|
||||
for (Map.Entry<String, String> entry : itemAttributes.entrySet()) {
|
||||
if (entry.getKey().equalsIgnoreCase(attributeName)) {
|
||||
itemValue = entry.getValue();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (itemValue == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the required values contain commas (AND logic for multiple values)
|
||||
if (requiredValuesStr.contains(",")) {
|
||||
String[] requiredValues = requiredValuesStr.split(",");
|
||||
// ALL values must be present in the item's attribute value
|
||||
for (String requiredValue : requiredValues) {
|
||||
if (!itemValue.toLowerCase().contains(requiredValue.trim().toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
// Single value - simple case-insensitive comparison
|
||||
return itemValue.equalsIgnoreCase(requiredValuesStr);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@ReflectionMarker
|
||||
@AllArgsConstructor
|
||||
|
||||
@ -13938,6 +13938,14 @@
|
||||
"name": "getCustomParameters",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "getAttributeWhitelist",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "getAttributeWhitelistCategories",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "getDisabledAt",
|
||||
"parameterTypes": []
|
||||
@ -14102,6 +14110,18 @@
|
||||
"java.util.List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setAttributeWhitelist",
|
||||
"parameterTypes": [
|
||||
"java.util.List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setAttributeWhitelistCategories",
|
||||
"parameterTypes": [
|
||||
"java.util.List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setDisabledAt",
|
||||
"parameterTypes": [
|
||||
@ -47757,6 +47777,26 @@
|
||||
"name": "getCustomParameters",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "getAttributeWhitelist",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "getAttributeWhitelistCategories",
|
||||
"parameterTypes": []
|
||||
},
|
||||
{
|
||||
"name": "setAttributeWhitelist",
|
||||
"parameterTypes": [
|
||||
"java.util.List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setAttributeWhitelistCategories",
|
||||
"parameterTypes": [
|
||||
"java.util.List"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "setName",
|
||||
"parameterTypes": [
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
#@formatter:off
|
||||
- version: "v8.2.0"
|
||||
- version: "v8.3.0"
|
||||
changes:
|
||||
- type: "feature"
|
||||
text: "Add attribute whitelist filtering for indexers. Allows filtering search results by newznab attributes (e.g., subtitles). Configure per-indexer with optional category restrictions. See #983"
|
||||
- type: "feature"
|
||||
text: "Option to import prowlarr indexers. They will be added as separate entries with a (Prowlarr) suffix. This different than using the external tool configuration as the prowlarr indexers will be searched via prowlarr, i.e. when you search them in hydra it will call prowlarr which then calls the indexers. See #922"
|
||||
- type: "feature"
|
||||
|
||||
@ -4215,13 +4215,51 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
type: 'text',
|
||||
required: false,
|
||||
label: 'Custom parameters',
|
||||
help: 'Define custom parameters to be sent to the indexer when searching. Use the format "name=value"Apply values with return key.',
|
||||
help: 'Define custom parameters to be sent to the indexer when searching. Use the format "name=value". Apply values with return key.',
|
||||
advanced: 'true'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'attributeWhitelist',
|
||||
type: 'horizontalChips',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: false,
|
||||
label: 'Attribute whitelist',
|
||||
help: 'Only accept results with matching attributes. Use format "name=value" (e.g., "subs=English"). Multiple entries use OR logic. Comma-separated values use AND logic (e.g., "subs=English,French" requires both). Apply values with return key. Results with no attributes will be rejected.',
|
||||
advanced: 'true'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
var cats = CategoriesService.getWithoutAll();
|
||||
var categoryOptions = _.map(cats, function (x) {
|
||||
return {id: x.name, label: x.name}
|
||||
});
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'attributeWhitelistCategories',
|
||||
type: 'horizontalMultiselect',
|
||||
hideExpression: '!model.attributeWhitelist || model.attributeWhitelist.length === 0',
|
||||
templateOptions: {
|
||||
label: 'Whitelist categories',
|
||||
help: 'Apply attribute whitelist only to results in these categories. Selecting none applies the whitelist to all categories.',
|
||||
options: categoryOptions,
|
||||
settings: {
|
||||
showSelectedValues: false,
|
||||
noSelectedText: "All"
|
||||
},
|
||||
advanced: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'preselect',
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -313,5 +313,180 @@ public class SearchResultAcceptorTest {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptWhenNoWhitelistConfigured() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Collections.emptyList());
|
||||
item.setTitle("Some result");
|
||||
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectItemWithNoAttributesWhenWhitelistConfigured() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
item.setTitle("Some result");
|
||||
// item has no attributes set (empty map by default)
|
||||
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptItemMatchingSingleWhitelistEntry() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
item.setTitle("Some result");
|
||||
item.getAttributes().put("subs", "English");
|
||||
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptItemMatchingWhitelistEntryCaseInsensitive() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=english"));
|
||||
item.setTitle("Some result");
|
||||
item.getAttributes().put("SUBS", "ENGLISH");
|
||||
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectItemNotMatchingWhitelistEntry() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
item.setTitle("Some result");
|
||||
item.getAttributes().put("subs", "French");
|
||||
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptItemMatchingAnyWhitelistEntryOrLogic() {
|
||||
// OR logic: accept if subs=English OR subs=French
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English", "subs=French"));
|
||||
item.setTitle("Some result");
|
||||
|
||||
// English matches first entry
|
||||
item.getAttributes().put("subs", "English");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// French matches second entry
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "French");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// German doesn't match any
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "German");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRequireAllValuesForCommaSeparatedAndLogic() {
|
||||
// AND logic for comma-separated: subs=English,French requires BOTH to be in the value
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English,French"));
|
||||
item.setTitle("Some result");
|
||||
|
||||
// Value contains both English and French
|
||||
item.getAttributes().put("subs", "English French German");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// Value contains only English
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "English");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
|
||||
// Value contains only French
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "French");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldCombineOrAndAndLogicCorrectly() {
|
||||
// Two entries: "subs=English,French" (requires both) OR "subs=Japanese"
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English,French", "subs=Japanese"));
|
||||
item.setTitle("Some result");
|
||||
|
||||
// Match first entry (both English and French)
|
||||
item.getAttributes().put("subs", "English French");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// Match second entry (Japanese)
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "Japanese");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// Only English doesn't match either entry
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "English");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectItemWithWrongAttribute() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
item.setTitle("Some result");
|
||||
// Item has a different attribute, not 'subs'
|
||||
item.getAttributes().put("language", "English");
|
||||
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplyWhitelistOnlyToConfiguredCategories() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
when(indexerConfig.getAttributeWhitelistCategories()).thenReturn(Arrays.asList("Movies"));
|
||||
item.setTitle("Some result");
|
||||
|
||||
// Item in Movies category without matching attributes - should be rejected
|
||||
Category moviesCategory = new Category();
|
||||
moviesCategory.setName("Movies");
|
||||
item.setCategory(moviesCategory);
|
||||
item.getAttributes().put("subs", "French");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
|
||||
// Item in Movies category with matching attributes - should be accepted
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "English");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
|
||||
// Item in TV category without matching attributes - should be accepted (category not in whitelist categories)
|
||||
Category tvCategory = new Category();
|
||||
tvCategory.setName("TV");
|
||||
item.setCategory(tvCategory);
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "French");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldApplyWhitelistToAllCategoriesWhenNoCategoriesConfigured() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
when(indexerConfig.getAttributeWhitelistCategories()).thenReturn(Collections.emptyList());
|
||||
item.setTitle("Some result");
|
||||
|
||||
// Item in any category without matching attributes - should be rejected
|
||||
Category moviesCategory = new Category();
|
||||
moviesCategory.setName("Movies");
|
||||
item.setCategory(moviesCategory);
|
||||
item.getAttributes().put("subs", "French");
|
||||
assertThat(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item)).isFalse();
|
||||
|
||||
// Item with matching attributes - should be accepted
|
||||
item.getAttributes().clear();
|
||||
item.getAttributes().put("subs", "English");
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAcceptItemWithNullCategoryWhenCategoriesConfigured() {
|
||||
when(indexerConfig.getAttributeWhitelist()).thenReturn(Arrays.asList("subs=English"));
|
||||
when(indexerConfig.getAttributeWhitelistCategories()).thenReturn(Arrays.asList("Movies"));
|
||||
item.setTitle("Some result");
|
||||
item.setCategory(null);
|
||||
item.getAttributes().put("subs", "French");
|
||||
|
||||
// Item with null category should be accepted (skip filtering) when categories are configured
|
||||
assertTrue(testee.checkAttributeWhitelist(indexerConfig, HashMultiset.create(), item));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -414,13 +414,51 @@ function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesSer
|
||||
type: 'text',
|
||||
required: false,
|
||||
label: 'Custom parameters',
|
||||
help: 'Define custom parameters to be sent to the indexer when searching. Use the format "name=value"Apply values with return key.',
|
||||
help: 'Define custom parameters to be sent to the indexer when searching. Use the format "name=value". Apply values with return key.',
|
||||
advanced: 'true'
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'attributeWhitelist',
|
||||
type: 'horizontalChips',
|
||||
templateOptions: {
|
||||
type: 'text',
|
||||
required: false,
|
||||
label: 'Attribute whitelist',
|
||||
help: 'Only accept results with matching attributes. Use format "name=value" (e.g., "subs=English"). Multiple entries use OR logic. Comma-separated values use AND logic (e.g., "subs=English,French" requires both). Apply values with return key. Results with no attributes will be rejected.',
|
||||
advanced: 'true'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
var cats = CategoriesService.getWithoutAll();
|
||||
var categoryOptions = _.map(cats, function (x) {
|
||||
return {id: x.name, label: x.name}
|
||||
});
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'attributeWhitelistCategories',
|
||||
type: 'horizontalMultiselect',
|
||||
hideExpression: '!model.attributeWhitelist || model.attributeWhitelist.length === 0',
|
||||
templateOptions: {
|
||||
label: 'Whitelist categories',
|
||||
help: 'Apply attribute whitelist only to results in these categories. Selecting none applies the whitelist to all categories.',
|
||||
options: categoryOptions,
|
||||
settings: {
|
||||
showSelectedValues: false,
|
||||
noSelectedText: "All"
|
||||
},
|
||||
advanced: true
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
fieldset.push(
|
||||
{
|
||||
key: 'preselect',
|
||||
|
||||
@ -113,6 +113,8 @@ public class IndexerConfig {
|
||||
private String username = null;
|
||||
private String userAgent = null;
|
||||
private String vipExpirationDate;
|
||||
private List<String> attributeWhitelist = new ArrayList<>();
|
||||
private List<String> attributeWhitelistCategories = new ArrayList<>();
|
||||
|
||||
public Optional<String> getApiPath() {
|
||||
return Optional.ofNullable(apiPath);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user