[Feature] Filter Meta Indexer by tag and by language (#11662). resolves #8884 resolves #7170 resolves #4787 resolves #2185

* bump to 0.18.*

Also partially addresses https://github.com/Jackett/Jackett/issues/661 (if user adds `enabled` and `disabled` tags).

Co-authored-by: garfield69 <garfieldsixtynine@gmail.com>
Co-authored-by: ilike2burnthing <59480337+ilike2burnthing@users.noreply.github.com>
This commit is contained in:
Alessio Gogna 2021-05-08 22:24:18 +02:00 committed by GitHub
parent b07543bff6
commit 66bec102db
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1091 additions and 170 deletions

View File

@ -2,7 +2,7 @@
name: $(majorVersion).$(minorVersion).$(patchVersion)
variables:
majorVersion: 0
minorVersion: 17
minorVersion: 18
patchVersion: $[counter(variables['minorVersion'], 1)] # this will reset when we bump minor
jackettVersion: $(majorVersion).$(minorVersion).$(patchVersion)
buildConfiguration: Release

File diff suppressed because one or more lines are too long

View File

@ -76,6 +76,10 @@ body {
max-width: 255px;
}
.setup-item-inputtags {
max-width: 255px;
}
[data-type=hiddendata]{
display: none;
}
@ -328,3 +332,21 @@ input#searchquery {
#proxy-warning {
color: red;
}
.label-tag {
text-transform: lowercase;
background-color: #777;
}
.tagify {
height: auto;
}
.tagify .tagify__input {
min-width: 0;
text-transform: lowercase;
}
.tagify .tagify__tag-text {
text-transform: lowercase;
}

View File

@ -3,6 +3,8 @@ var basePath = '';
var indexers = [];
var configuredIndexers = [];
var unconfiguredIndexers = [];
var configuredTags = [];
var availableFilters = [];
$.fn.inView = function () {
if (!this.length) return false;
@ -58,7 +60,7 @@ function openSearchIfNecessary() {
decodeURIComponent(item.split('=')[1].replace(/\+/g, '%20')))
}, prev), {});
if ("search" in hashArgs) {
showSearch(hashArgs.tracker, hashArgs.search, hashArgs.category);
showSearch(hashArgs.filter, hashArgs.tracker, hashArgs.search, hashArgs.category);
}
}
@ -67,6 +69,14 @@ function insertWordWrap(str) {
return str.replace(/([\.\-_\/\\])/g, "$1\u200B");
}
function type_filter(indexer) {
return indexer.type == this.value;
}
function tag_filter(indexer) {
return indexer.tags.map(t => t.toLowerCase()).indexOf(this.value.toLowerCase()) > -1;
}
function getJackettConfig(callback) {
api.getServerConfig(callback).fail(function () {
doNotify("Error loading Jackett settings, request to Jackett server failed, is server running ?", "danger", "glyphicon glyphicon-alert");
@ -131,11 +141,14 @@ function loadJackettSettings() {
}
function reloadIndexers() {
$('#filters').hide();
$('#indexers').hide();
api.getAllIndexers(function (data) {
indexers = data;
configuredIndexers = [];
unconfiguredIndexers = [];
configuredTags = [];
availableFilters = [];
for (var i = 0; i < data.length; i++) {
var item = data[i];
item.rss_host = resolveUrl(basePath + "/api/v2.0/indexers/" + item.id + "/results/torznab/api?apikey=" + api.key + "&t=search&cat=&q=");
@ -169,7 +182,13 @@ function reloadIndexers() {
else
unconfiguredIndexers.push(item);
}
configuredTags = configuredIndexers.map(i => i.tags).reduce((a, g) => a.concat(g), []).filter((v, i, a) => a.indexOf(v) === i);
configureFilters(configuredIndexers);
displayConfiguredIndexersList(configuredIndexers);
$('#indexers div.dataTables_filter input').focusWithoutScrolling();
openSearchIfNecessary();
}).fail(function () {
@ -177,6 +196,23 @@ function reloadIndexers() {
});
}
function configureFilters(indexers) {
function add(f) {
if (availableFilters.find(x => x.id == f.id))
return;
if (!indexers.every(f.apply, f) && indexers.some(f.apply, f))
availableFilters.push(f);
}
["public", "private", "semi-private"]
.map(t => { return { id: "type:" + t, apply: type_filter, value: t } })
.forEach(add);
configuredTags.sort()
.map(t => { return { id: "tag:" + t.toLowerCase(), apply: tag_filter, value: t }})
.forEach(add);
}
function displayConfiguredIndexersList(indexers) {
var indexersTemplate = Handlebars.compile($("#configured-indexer-table").html());
var indexersTable = $(indexersTemplate({
@ -484,17 +520,20 @@ function prepareSearchButtons(element) {
var id = $btn.data("id");
$btn.click(function () {
window.location.hash = "search&tracker=" + id;
showSearch(id);
showSearch(null, id);
});
});
}
function prepareSetupButtons(element) {
element.find('.indexer-setup').each(function (i, btn) {
var indexer = configuredIndexers[i];
$(btn).click(function () {
displayIndexerSetup(indexer.id, indexer.name, indexer.caps, indexer.link, indexer.alternativesitelinks, indexer.description);
});
var $btn = $(btn);
var id = $btn.data("id");
var indexer = configuredIndexers.find(i => i.id === id);
if (indexer)
$btn.click(function () {
displayIndexerSetup(indexer.id, indexer.name, indexer.caps, indexer.link, indexer.alternativesitelinks, indexer.description);
});
});
}
@ -610,11 +649,32 @@ function populateConfigItems(configForm, config) {
var item = config[i];
var setupValueTemplate = Handlebars.compile($("#setup-item-" + item.type).html());
item.value_element = setupValueTemplate(item);
var template = setupItemTemplate(item);
var template = $(setupItemTemplate(item));
$formItemContainer.append(template);
setupConfigItem(template, item);
}
}
function setupConfigItem(configItem, item) {
switch (item.type) {
case "inputtags": {
configItem.find("input").tagify({
dropdown: {
enabled: 0,
position: "text"
},
separator: item.separator || ",",
whitelist: item.whitelist || [],
blacklist: item.blacklist || [],
pattern: item.pattern || null,
delimiters: item.delimiters || item.separator || ",",
originalInputValueFormat: function (values) { return values.map(item => item.value.toLowerCase()).join(this.separator); }
});
}
break;
}
}
function newConfigModal(title, config, caps, link, alternativesitelinks, description) {
var configTemplate = Handlebars.compile($("#jackett-config-setup-modal").html());
var configForm = $(configTemplate({
@ -638,6 +698,8 @@ function newConfigModal(title, config, caps, link, alternativesitelinks, descrip
});
}
$("div[data-id='tags'] input", configForm).data("tagify").settings.whitelist = configuredTags;
return configForm;
}
@ -668,9 +730,13 @@ function getConfigModalJson(configForm) {
$el.find(".setup-item-inputcheckbox input:checked").each(function () {
itemEntry.values.push($(this).val());
});
break;
case "inputselect":
itemEntry.value = $el.find(".setup-item-inputselect select").val();
break;
case "inputtags":
itemEntry.value = $el.find(".setup-item-inputtags input").val();
break;
}
configJson.push(itemEntry)
});
@ -802,14 +868,15 @@ function updateReleasesRow(row) {
}
}
function showSearch(selectedIndexer, query, category) {
function showSearch(selectedFilter, selectedIndexer, query, category) {
var selectedIndexers = [];
if (selectedIndexer)
selectedIndexers = selectedIndexer.split(",");
selectedIndexers = selectedIndexer.split(",");
$('#select-indexer-modal').remove();
var releaseTemplate = Handlebars.compile($("#jackett-search").html());
var releaseDialog = $(releaseTemplate({
indexers: configuredIndexers
filters: availableFilters,
active: selectedFilter
}));
$("#modals").append(releaseDialog);
@ -823,6 +890,29 @@ function showSearch(selectedIndexer, query, category) {
window.location.hash = '';
});
var setTrackers = function (filterId, trackers) {
var select = $('#searchTracker');
var selected = select.val();
var filter = availableFilters.find(f => f.id == filterId);
if (filter)
trackers = trackers.filter(filter.apply,filter);
var options = trackers.map(t => {
return {
label: t.name,
value: t.id
}
});
select.multiselect('dataprovider', options);
select.val(selected).multiselect("refresh");
};
$('#searchFilter').change(jQuery.proxy(function () {
var filterId = $('#searchFilter').val();
setTrackers(filterId, this.items);
}, {
items: configuredIndexers
}));
var setCategories = function (trackers, items) {
var cats = {};
for (var i = 0; i < items.length; i++) {
@ -869,6 +959,7 @@ function showSearch(selectedIndexer, query, category) {
return;
}
var searchString = releaseDialog.find('#searchquery').val();
var filterId = releaseDialog.find('#searchFilter').val();
var queryObj = {
Query: searchString,
Category: releaseDialog.find('#searchCategory').val(),
@ -878,14 +969,15 @@ function showSearch(selectedIndexer, query, category) {
window.location.hash = Object.entries({
search: encodeURIComponent(queryObj.Query).replace(/%20/g, '+'),
tracker: queryObj.Tracker.join(","),
category: queryObj.Category.join(",")
}).map(([k, v], i) => k + '=' + v).join('&');
category: queryObj.Category.join(","),
filter: filterId ? encodeURIComponent(filterId) : ""
}).filter(([k, v]) => v).map(([k, v], i) => k + '=' + v).join('&');
$('#jackett-search-perform').html($('#spinner').html());
$('#searchResults div.dataTables_filter input').val("");
clearSearchResultTable($('#searchResults'));
var trackerId = "all";
var trackerId = filterId || "all";
api.resultsForIndexer(trackerId, queryObj, function (data) {
for (var i = 0; i < data.Results.length; i++) {
var item = data.Results[i];
@ -906,16 +998,14 @@ function showSearch(selectedIndexer, query, category) {
var searchTracker = releaseDialog.find("#searchTracker");
var searchCategory = releaseDialog.find('#searchCategory');
searchCategory.multiselect({
var searchFilter = releaseDialog.find('#searchFilter');
searchFilter.multiselect({
maxHeight: 400,
enableFiltering: true,
includeSelectAllOption: true,
enableCaseInsensitiveFiltering: true,
nonSelectedText: 'Any'
nonSelectedText: 'All'
});
if (selectedIndexers)
searchTracker.val(selectedIndexers);
searchTracker.trigger("change");
updateSearchResultTable($('#searchResults'), []);
clearSearchResultTable($('#searchResults'));
@ -928,6 +1018,29 @@ function showSearch(selectedIndexer, query, category) {
nonSelectedText: 'All'
});
searchCategory.multiselect({
maxHeight: 400,
enableFiltering: true,
includeSelectAllOption: true,
enableCaseInsensitiveFiltering: true,
nonSelectedText: 'Any'
});
if (availableFilters.length > 0) {
if (selectedFilter) {
searchFilter.val(selectedFilter);
searchFilter.multiselect("refresh");
}
searchFilter.trigger("change");
}
else
setTrackers(selectedFilter, configuredIndexers);
if (selectedIndexers) {
searchTracker.val(selectedIndexers);
searchTracker.multiselect("refresh");
}
searchTracker.trigger("change");
if (category !== undefined) {
searchCategory.val(category.split(","));
@ -1231,7 +1344,7 @@ function bindUIButtons() {
});
$("#jackett-show-search").click(function () {
showSearch(null);
showSearch();
window.location.hash = "search";
});
@ -1348,4 +1461,4 @@ function proxyWarning(input) {
} else {
$('#proxy-warning').hide();
}
}
}

View File

@ -330,3 +330,21 @@ input#searchquery {
#proxy-warning {
color: red;
}
.label-tag {
text-transform: lowercase;
background-color: #777;
}
.tagify {
height: auto;
}
.tagify .tagify__input {
min-width: 0;
text-transform: lowercase;
}
.tagify .tagify__tag-text {
text-transform: lowercase;
}

View File

@ -22,11 +22,14 @@
<script type="text/javascript" src="../bootstrap/bootstrap.min.js?changed=2017083001"></script>
<script type="text/javascript" src="../libs/bootstrap-notify.js?changed=2017083001"></script>
<script type="text/javascript" src="../libs/bootstrap-multiselect.js?changed=2017083001"></script>
<script type="text/javascript" src="../libs/tagify.min.js?changed=11662"></script>
<script type="text/javascript" src="../libs/jQuery.tagify.min.js?changed=11662"></script>
<link rel="stylesheet" type="text/css" href="../bootstrap/bootstrap.min.css?changed=2017083001">
<link rel="stylesheet" type="text/css" href="../animate.css?changed=2017083001">
<link rel="stylesheet" type="text/css" href="../custom.css?changed=20201208" media="only screen and (min-device-width: 480px)">
<link rel="stylesheet" type="text/css" href="../custom_mobile.css?changed=20201208" media="only screen and (max-device-width: 480px)">
<link rel="stylesheet" type="text/css" href="../css/tagify.css?changed=11662">
<link rel="stylesheet" type="text/css" href="../custom.css?changed=11662" media="only screen and (min-device-width: 480px)">
<link rel="stylesheet" type="text/css" href="../custom_mobile.css?changed=11662" media="only screen and (max-device-width: 480px)">
<link rel="stylesheet" type="text/css" href="../css/jquery.dataTables.min.css?changed=2017083001">
<link rel="stylesheet" type="text/css" href="../css/bootstrap-multiselect.css?changed=2017083001" />
<link rel="stylesheet" type="text/css" href="../css/font-awesome.min.css?changed=2017083001">
@ -65,7 +68,7 @@
</button>
</div>
<h3>Configured Indexers</h3>
<div id="indexers"> </div>
<div id="indexers"></div>
<hr />
<div class="input-area">
@ -213,7 +216,7 @@
<div id="modals"></div>
<script id="setup-item" type="text/x-handlebars-template">
<div class="setup-item form-group" data-id="{{id}}" data-value="{{value}}" data-type="{{type}}">
<div class="setup-item form-filter" data-id="{{id}}" data-value="{{value}}" data-type="{{type}}">
<div class="setup-item-label">{{name}}</div>
<div class="setup-item-value">{{{value_element}}}</div>
</div>
@ -289,10 +292,14 @@
Click on an URL to copy it to the Site Link field.
</div>
</script>
<script id="setup-item-inputtags" type="text/x-handlebars-template">
<div class="setup-item-inputtags">
<input class="form-control input-sm" type="text" value="{{{value}}}" {{#if pattern}} pattern="{{pattern}}"{{/if}}/>
</div>
</script>
<script id="configured-indexer-table" type="text/x-handlebars-template">
<div class="configured-indexer-div">
<div class="tab-content configured-indexer-div">
<table id="configured-indexer-datatable" class="indexer-table dataTable compact cell-border hover stripe table table-responsive">
<thead>
<tr>
@ -303,7 +310,7 @@
<tbody>
{{#each indexers}}
<tr class="configured-indexer-row">
<td><a target="_blank" href="{{site_link}}" title="{{description}}">{{name}}</a> <span title="{{type}}" class="label label-{{type_label}}" style="text-transform: capitalize;">{{type}}</span></td>
<td><a target="_blank" href="{{site_link}}" title="{{description}}">{{name}}</a> <span title="{{type}}" class="label label-{{type_label}}" style="text-transform: capitalize;">{{type}}</span>{{#each tags}} <span title="{{this}}" class="label label-tag">{{this}}</span>{{/each}}</td>
<td class="fit">
<div class="indexer-buttons">
<a href="{{rss_host}}" title="{{rss_host}}" role="button" class="indexer-button-copy btn btn-xs btn-info">Copy RSS Feed</i></a>
@ -492,12 +499,17 @@
<p>You can search all configured indexers from this screen.</p>
<label for="text">Query</label>
<input class="form-control" type="text" name="query" id="searchquery" />
<label for="tracker">Tracker</label>
<select name="tracker" id="searchTracker" multiple="multiple">
{{#each indexers}}
<option value="{{id}}" selected>{{name}}</option>
{{/each}}
{{#if filters}}
<label for="filter">Filter</label>
<select name="filter" id="searchFilter">
<option value="all">all</option>
{{#each filters}}
<option value="{{id}}">{{id}}</option>
{{/each}}
</select>
{{/if}}
<label for="tracker">Tracker</label>
<select name="tracker" id="searchTracker" multiple="multiple"></select>
<label for="category">Category</label>
<select name="category" id="searchCategory" multiple="multiple"></select>
<button id="jackett-search-perform" class="btn btn-success btn-sm"><span class="fa fa-search"></span></button>
@ -698,6 +710,6 @@
</script>
<script type="text/javascript" src="../libs/api.js?changed=2017083001"></script>
<script type="text/javascript" src="../custom.js?changed=20210424"></script>
<script type="text/javascript" src="../custom.js?changed=11662"></script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -34,6 +34,8 @@ namespace Jackett.Common.Indexers
public Encoding Encoding { get; protected set; }
public virtual bool IsConfigured { get; protected set; }
public virtual string[] Tags { get; protected set; }
protected Logger logger;
protected IIndexerConfigurationService configurationService;
protected IProtectionService protectionService;
@ -148,6 +150,8 @@ namespace Jackett.Common.Indexers
// check whether the site link is well-formatted
var siteUri = new Uri(configData.SiteLink.Value);
SiteLink = configData.SiteLink.Value;
Tags = configData.Tags.Values.Select(t => t.ToLowerInvariant()).ToArray();
}
public void LoadFromSavedConfiguration(JToken jsonConfig)

View File

@ -40,6 +40,8 @@ namespace Jackett.Common.Indexers
// Whether this indexer has been configured, verified and saved in the past and has the settings required for functioning
bool IsConfigured { get; }
string[] Tags { get; }
// Retrieved for starting setup for the indexer via web API
Task<ConfigurationData> GetConfigurationForSetup();

View File

@ -67,7 +67,7 @@ namespace Jackett.Common.Indexers.Meta
protected override async Task<IEnumerable<ReleaseInfo>> PerformQuery(TorznabQuery query)
{
var indexers = validIndexers;
var indexers = ValidIndexers;
IEnumerable<Task<IndexerResult>> supportedTasks = indexers.Where(i => i.CanHandleQuery(query)).Select(i => i.ResultsForQuery(query, true)).ToList(); // explicit conversion to List to execute LINQ query
var fallbackStrategies = fallbackStrategyProvider.FallbackStrategiesForQuery(query);
@ -109,11 +109,13 @@ namespace Jackett.Common.Indexers.Meta
return result;
}
public override TorznabCapabilities TorznabCaps => validIndexers.Select(i => i.TorznabCaps).Aggregate(new TorznabCapabilities(), TorznabCapabilities.Concat);
public override TorznabCapabilities TorznabCaps => ValidIndexers.Select(i => i.TorznabCaps).Aggregate(new TorznabCapabilities(), TorznabCapabilities.Concat);
public override bool IsConfigured => Indexers != null;
private IEnumerable<IIndexer> validIndexers => Indexers?.Where(i => i.IsConfigured && filterFunc(i));
public override string[] Tags => Array.Empty<string>();
public IEnumerable<IIndexer> ValidIndexers => Indexers?.Where(i => i.IsConfigured && filterFunc(i));
public IEnumerable<IIndexer> Indexers;

View File

@ -1,7 +1,10 @@
using System;
using System.Linq;
using Jackett.Common.Models;
using Jackett.Common.Models.IndexerConfig;
using Jackett.Common.Services.Interfaces;
using Jackett.Common.Utils.Clients;
using NLog;
namespace Jackett.Common.Indexers.Meta
@ -37,4 +40,41 @@ namespace Jackett.Common.Indexers.Meta
}
}
}
public class FilterIndexer : BaseMetaIndexer
{
public FilterIndexer(string filter, IFallbackStrategyProvider fallbackStrategyProvider,
IResultFilterProvider resultFilterProvider, IIndexerConfigurationService configService,
WebClient client, Logger logger, IProtectionService ps, ICacheService cs, Func<IIndexer, bool> filterFunc)
: base(id: filter,
name: filter,
description: "This feed includes all configured trackers filter by "+filter,
configService: configService,
client: client,
logger: logger,
ps: ps,
cs: cs,
configData: new ConfigurationData(),
fallbackStrategyProvider: fallbackStrategyProvider,
resultFilterProvider: resultFilterProvider,
filter: filterFunc
)
{
}
public override TorznabCapabilities TorznabCaps
{
get
{
// increase the limits (workaround until proper paging is supported, issue #1661)
var caps = base.TorznabCaps;
caps.LimitsMax = caps.LimitsDefault = 1000;
return caps;
}
}
public override bool IsConfigured => base.IsConfigured && (ValidIndexers?.Any() ?? false);
public override void SaveConfig() { }
}
}

View File

@ -58,6 +58,9 @@
<Content Include="Content\css\jquery.dataTables.min.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\css\tagify.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
@ -124,9 +127,15 @@
<Content Include="Content\libs\jquery.min.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\libs\jQuery.tagify.min.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\libs\moment.min.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\libs\tagify.min.js">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Content\login.html">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>

View File

@ -34,6 +34,8 @@ namespace Jackett.Common.Models.DTO
[DataMember]
public string language { get; private set; }
[DataMember]
public IEnumerable<string> tags { get; private set; }
[DataMember]
public string last_error { get; private set; }
[DataMember]
public bool potatoenabled { get; private set; }
@ -55,6 +57,8 @@ namespace Jackett.Common.Models.DTO
alternativesitelinks = indexer.AlternativeSiteLinks;
tags = indexer.Tags;
caps = indexer.TorznabCaps.Categories.GetTorznabCategoryList(true)
.Select(c => new Capability
{

View File

@ -1,8 +1,10 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using Jackett.Common.Services.Interfaces;
using Jackett.Common.Utils;
using Newtonsoft.Json.Linq;
namespace Jackett.Common.Models.IndexerConfig
@ -12,9 +14,10 @@ namespace Jackett.Common.Models.IndexerConfig
private const string PASSWORD_REPLACEMENT = "|||%%PREVJACKPASSWD%%|||";
protected Dictionary<string, ConfigurationItem> dynamics = new Dictionary<string, ConfigurationItem>(); // list for dynamic items
public HiddenStringConfigurationItem CookieHeader { get; private set; } = new HiddenStringConfigurationItem(name:"CookieHeader");
public HiddenStringConfigurationItem LastError { get; private set; } = new HiddenStringConfigurationItem(name:"LastError");
public StringConfigurationItem SiteLink { get; private set; } = new StringConfigurationItem(name:"Site Link");
public HiddenStringConfigurationItem CookieHeader { get; private set; } = new HiddenStringConfigurationItem(name: "CookieHeader");
public HiddenStringConfigurationItem LastError { get; private set; } = new HiddenStringConfigurationItem(name: "LastError");
public StringConfigurationItem SiteLink { get; private set; } = new StringConfigurationItem(name: "Site Link");
public TagsConfigurationItem Tags { get; private set; } = new TagsConfigurationItem(name: "Tags", charSet:"A-Za-z0-9\\-\\._~");
public ConfigurationData()
{
@ -36,67 +39,10 @@ namespace Jackett.Common.Models.IndexerConfig
var jsonToken = jsonArray.FirstOrDefault(f => f.Value<string>("id") == item.ID);
if (jsonToken == null)
continue;
switch (item)
{
case StringConfigurationItem stringItem:
{
if (HasPasswordValue(item))
{
var pw = ReadValueAs<string>(jsonToken);
if (pw != PASSWORD_REPLACEMENT)
{
stringItem.Value = ps != null ? ps.UnProtect(pw) : pw;
}
}
else
{
stringItem.Value = ReadValueAs<string>(jsonToken);
}
break;
}
case HiddenStringConfigurationItem hiddenStringItem:
{
hiddenStringItem.Value = ReadValueAs<string>(jsonToken);
break;
}
case BoolConfigurationItem boolItem:
{
boolItem.Value = ReadValueAs<bool>(jsonToken);
break;
}
case SingleSelectConfigurationItem singleSelectItem:
{
singleSelectItem.Value = ReadValueAs<string>(jsonToken);
break;
}
case MultiSelectConfigurationItem multiSelectItem:
{
var values = jsonToken.Value<JArray>("values");
if (values != null)
{
multiSelectItem.Values = values.Values<string>().ToArray();
}
break;
}
case PasswordConfigurationItem passwordItem:
{
var pw = ReadValueAs<string>(jsonToken);
if (pw != PASSWORD_REPLACEMENT)
{
passwordItem.Value = ps != null ? ps.UnProtect(pw) : pw;
}
break;
}
}
item.FromJson(jsonToken, ps);
}
}
private T ReadValueAs<T>(JToken jToken) => jToken.Value<T>("value");
private bool HasPasswordValue(ConfigurationItem item)
=> string.Equals(item.Name, "password", StringComparison.InvariantCultureIgnoreCase);
public JToken ToJson(IProtectionService ps, bool forDisplay = true)
{
var jArray = new JArray();
@ -104,43 +50,7 @@ namespace Jackett.Common.Models.IndexerConfig
var configurationItems = GetConfigurationItems(forDisplay);
foreach (var configurationItem in configurationItems)
{
JObject jObject = null;
switch (configurationItem)
{
case ConfigurationItemMaybePassword maybePassword:
{
// Remove this code and give each derived ConfigurationItem class its own ToJson method
// as soon as everyone is using PasswordConfigurationItem for passwords.
jObject = maybePassword.ToJson(ps);
break;
}
case BoolConfigurationItem boolItem:
{
jObject = boolItem.ToJson();
break;
}
case SingleSelectConfigurationItem singleSelectItem:
{
jObject = singleSelectItem.ToJson();
break;
}
case MultiSelectConfigurationItem multiSelectItem:
{
jObject = multiSelectItem.ToJson();
break;
}
case DisplayImageConfigurationItem imageItem:
{
jObject = imageItem.ToJson();
break;
}
case PasswordConfigurationItem passwordItem:
{
jObject = passwordItem.ToJson(forDisplay, ps);
break;
}
}
var jObject = configurationItem.ToJson(ps, forDisplay);
if (jObject != null)
{
@ -163,8 +73,13 @@ namespace Jackett.Common.Models.IndexerConfig
properties.Remove(SiteLink);
properties.Insert(0, SiteLink);
// remove/insert Tags manualy to make sure it shows up last
properties.Remove(Tags);
properties.AddRange(dynamics.Values);
properties.Add(Tags);
return properties;
}
@ -204,6 +119,14 @@ namespace Jackett.Common.Models.IndexerConfig
["name"] = Name
};
}
protected static T ReadValueAs<T>(JToken jToken) => jToken.Value<T>("value");
protected static bool HasPasswordValue(ConfigurationItem item)
=> string.Equals(item.Name, "password", StringComparison.InvariantCultureIgnoreCase);
public virtual JObject ToJson(IProtectionService protectionService = null, bool forDisplay = true) => null;
public virtual void FromJson(JToken jsonToken, IProtectionService protectionService = null) { }
}
/// <summary>
@ -218,7 +141,7 @@ namespace Jackett.Common.Models.IndexerConfig
{
}
public JObject ToJson(IProtectionService protectionService = null)
public override JObject ToJson(IProtectionService protectionService = null, bool forDisplay = true)
{
var jObject = CreateJObject();
@ -245,6 +168,22 @@ namespace Jackett.Common.Models.IndexerConfig
: base(name, itemType: "inputstring")
{
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
if (HasPasswordValue(this))
{
var pw = ReadValueAs<string>(jsonToken);
if (pw != PASSWORD_REPLACEMENT)
{
Value = ps != null ? ps.UnProtect(pw) : pw;
}
}
else
{
Value = ReadValueAs<string>(jsonToken);
}
}
}
public class HiddenStringConfigurationItem : ConfigurationItemMaybePassword
@ -253,6 +192,11 @@ namespace Jackett.Common.Models.IndexerConfig
: base(name, itemType: "hiddendata", canBeShownToUser: false)
{
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
Value = ReadValueAs<string>(jsonToken);
}
}
public class DisplayInfoConfigurationItem : ConfigurationItemMaybePassword
@ -273,12 +217,17 @@ namespace Jackett.Common.Models.IndexerConfig
{
}
public JObject ToJson()
public override JObject ToJson(IProtectionService ps = null, bool forDisplay = true)
{
var jObject = CreateJObject();
jObject["value"] = Value;
return jObject;
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
Value = ReadValueAs<bool>(jsonToken);
}
}
public class DisplayImageConfigurationItem : ConfigurationItem
@ -290,7 +239,7 @@ namespace Jackett.Common.Models.IndexerConfig
{
}
public JObject ToJson()
public override JObject ToJson(IProtectionService ps = null, bool forDisplay = true)
{
var jObject = CreateJObject();
@ -310,7 +259,7 @@ namespace Jackett.Common.Models.IndexerConfig
public SingleSelectConfigurationItem(string name, Dictionary<string, string> options)
: base(name, itemType: "inputselect") => Options = options;
public JObject ToJson()
public override JObject ToJson(IProtectionService ps = null, bool forDisplay = true)
{
var jObject = CreateJObject();
@ -323,6 +272,11 @@ namespace Jackett.Common.Models.IndexerConfig
return jObject;
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
Value = ReadValueAs<string>(jsonToken);
}
}
public class MultiSelectConfigurationItem : ConfigurationItem
@ -334,7 +288,7 @@ namespace Jackett.Common.Models.IndexerConfig
public MultiSelectConfigurationItem(string name, Dictionary<string, string> options)
: base(name, itemType: "inputcheckbox") => Options = options;
public JObject ToJson()
public override JObject ToJson(IProtectionService ps, bool forDisplay)
{
var jObject = CreateJObject();
@ -347,6 +301,15 @@ namespace Jackett.Common.Models.IndexerConfig
return jObject;
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
var values = jsonToken.Value<JArray>("values");
if (values != null)
{
Values = values.Values<string>().ToArray();
}
}
}
public class PasswordConfigurationItem : ConfigurationItem
@ -358,7 +321,7 @@ namespace Jackett.Common.Models.IndexerConfig
{
}
public JObject ToJson(bool forDisplay, IProtectionService protectionService = null)
public override JObject ToJson(IProtectionService protectionService = null, bool forDisplay = true)
{
var jObject = CreateJObject();
@ -373,6 +336,76 @@ namespace Jackett.Common.Models.IndexerConfig
return jObject;
}
public override void FromJson(JToken jsonToken, IProtectionService ps = null)
{
var pw = ReadValueAs<string>(jsonToken);
if (pw != PASSWORD_REPLACEMENT)
{
Value = ps != null ? ps.UnProtect(pw) : pw;
}
}
}
public class TagsConfigurationItem : ConfigurationItem
{
public HashSet<string> Values { get; }
public string Pattern { get; set; }
public char Separator { get; set; }
public string Delimiters { get; set; }
public HashSet<string> Whitelist { get; }
public HashSet<string> Blacklist { get; }
public TagsConfigurationItem(string name, string charSet = null, char separator = ',')
: base(name, "inputtags")
{
Values = new HashSet<string>();
Whitelist = new HashSet<string>();
Blacklist = new HashSet<string>();
if (!string.IsNullOrWhiteSpace(charSet))
{
Pattern = $"^[{charSet}]+$";
Delimiters = $"[^{charSet}]+";
}
Separator = separator;
}
public override JObject ToJson(IProtectionService ps = null, bool forDisplay = true)
{
var jObject = CreateJObject();
var separator = Separator.ToString();
jObject["value"] = string.Join(separator, Values);
if (forDisplay)
{
jObject["separator"] = separator;
if (!string.IsNullOrWhiteSpace(Delimiters))
jObject["delimiters"] = Delimiters;
if (!string.IsNullOrWhiteSpace(Pattern))
jObject["pattern"] = Pattern;
if (Whitelist.Count > 0)
jObject["whitelist"] = string.Join(separator, Whitelist);
if (Blacklist.Count > 0)
jObject["blacklist"] = string.Join(separator, Blacklist);
}
return jObject;
}
public override void FromJson(JToken jsonToken, IProtectionService ps)
{
var value = ReadValueAs<string>(jsonToken);
if (value == null)
return;
Values.Clear();
var tags = Regex.Split(value, !string.IsNullOrWhiteSpace(Delimiters) ? Delimiters : $"{Separator}+").Select(t => t.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(Pattern))
tags = tags.Where(t => Whitelist.Contains(t) || Regex.IsMatch(t, Pattern));
if (Blacklist.Count > 0)
tags = tags.Where(t => !Blacklist.Contains(t));
foreach (var tag in tags)
Values.Add(tag);
}
}
}
}

View File

@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
@ -8,10 +9,12 @@ using Jackett.Common.Indexers.Meta;
using Jackett.Common.Models;
using Jackett.Common.Models.Config;
using Jackett.Common.Services.Interfaces;
using Jackett.Common.Utils;
using Jackett.Common.Utils.Clients;
using NLog;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
using FilterFunc = Jackett.Common.Utils.FilterFunc;
namespace Jackett.Common.Services
{
@ -29,6 +32,7 @@ namespace Jackett.Common.Services
private readonly Dictionary<string, IIndexer> indexers = new Dictionary<string, IIndexer>();
private AggregateIndexer aggregateIndexer;
private ConcurrentDictionary<string, IWebIndexer> availableFilters = new ConcurrentDictionary<string, IWebIndexer>();
// this map is used to maintain backward compatibility when renaming the id of an indexer
// (the id is used in the torznab/download/search urls and in the indexer configuration file)
@ -79,7 +83,7 @@ namespace Jackett.Common.Services
MigrateRenamedIndexers();
InitIndexers();
InitCardigannIndexers(path);
InitAggregateIndexer();
InitMetaIndexers();
RemoveLegacyConfigurations();
}
@ -218,28 +222,25 @@ namespace Jackett.Common.Services
logger.Info($"Loaded {indexers.Count} indexers in total");
}
public void InitAggregateIndexer()
public void InitMetaIndexers()
{
var omdbApiKey = serverConfig.OmdbApiKey;
IFallbackStrategyProvider fallbackStrategyProvider;
IResultFilterProvider resultFilterProvider;
if (!string.IsNullOrWhiteSpace(omdbApiKey))
{
var imdbResolver = new OmdbResolver(webClient, omdbApiKey, serverConfig.OmdbApiUrl);
fallbackStrategyProvider = new ImdbFallbackStrategyProvider(imdbResolver);
resultFilterProvider = new ImdbTitleResultFilterProvider(imdbResolver);
}
else
{
fallbackStrategyProvider = new NoFallbackStrategyProvider();
resultFilterProvider = new NoResultFilterProvider();
}
var (fallbackStrategyProvider, resultFilterProvider) = GetStrategyProviders();
logger.Info("Adding aggregate indexer ('all' indexer) ...");
aggregateIndexer = new AggregateIndexer(fallbackStrategyProvider, resultFilterProvider, configService, webClient, logger, protectionService, cacheService)
{
Indexers = indexers.Values
};
var predefinedFilters =
new[] { "public", "private", "semi-public" }
.Select(type => (filter: FilterFunc.Type.ToFilter(type), func: FilterFunc.Type.ToFunc(type)))
.Concat(
indexers.Values.SelectMany(x => x.Tags).Distinct()
.Select(tag => (filter: FilterFunc.Tag.ToFilter(tag), func: FilterFunc.Tag.ToFunc(tag)))
).Select(x => new KeyValuePair<string, IWebIndexer>(x.filter, CreateFilterIndexer(x.filter, x.func)));
availableFilters = new ConcurrentDictionary<string, IWebIndexer>(predefinedFilters);
}
public void RemoveLegacyConfigurations()
@ -271,16 +272,10 @@ namespace Jackett.Common.Services
This may stop working in the future.");
}
if (indexers.ContainsKey(realName))
return indexers[realName];
if (realName == "all")
return aggregateIndexer;
logger.Error($"Request for unknown indexer: {realName}");
throw new Exception($"Unknown indexer: {realName}");
return GetWebIndexer(realName);
}
public IWebIndexer GetWebIndexer(string name)
{
if (indexers.ContainsKey(name))
@ -289,6 +284,12 @@ namespace Jackett.Common.Services
if (name == "all")
return aggregateIndexer;
if (availableFilters.TryGetValue(name, out var indexer))
return indexer;
if (FilterFunc.TryParse(name, out var filterFunc))
return availableFilters.GetOrAdd(name, x => CreateFilterIndexer(name, filterFunc));
logger.Error($"Request for unknown indexer: {name}");
throw new Exception($"Unknown indexer: {name}");
}
@ -318,5 +319,47 @@ namespace Jackett.Common.Services
configService.Delete(indexer);
indexer.Unconfigure();
}
private IWebIndexer CreateFilterIndexer(string filter, Func<IIndexer, bool> filterFunc)
{
var (fallbackStrategyProvider, resultFilterProvider) = GetStrategyProviders();
logger.Info($"Adding filter indexer ('{filter}' indexer) ...");
return new FilterIndexer(
filter,
fallbackStrategyProvider,
resultFilterProvider,
configService,
webClient,
logger,
protectionService,
cacheService,
filterFunc
)
{
Indexers = indexers.Values
};
}
private (IFallbackStrategyProvider fallbackStrategyProvider, IResultFilterProvider resultFilterProvider)
GetStrategyProviders()
{
var omdbApiKey = serverConfig.OmdbApiKey;
IFallbackStrategyProvider fallbackStrategyProvider;
IResultFilterProvider resultFilterProvider;
if (!string.IsNullOrWhiteSpace(omdbApiKey))
{
var imdbResolver = new OmdbResolver(webClient, omdbApiKey, serverConfig.OmdbApiUrl);
fallbackStrategyProvider = new ImdbFallbackStrategyProvider(imdbResolver);
resultFilterProvider = new ImdbTitleResultFilterProvider(imdbResolver);
}
else
{
fallbackStrategyProvider = new NoFallbackStrategyProvider();
resultFilterProvider = new NoResultFilterProvider();
}
return (fallbackStrategyProvider, resultFilterProvider);
}
}
}

View File

@ -13,6 +13,6 @@ namespace Jackett.Common.Services.Interfaces
IEnumerable<IIndexer> GetAllIndexers();
void InitIndexers(IEnumerable<string> path);
void InitAggregateIndexer();
void InitMetaIndexers();
}
}

View File

@ -0,0 +1,58 @@
using System;
using System.Linq;
using Jackett.Common.Indexers;
using Jackett.Common.Utils.FilterFuncs;
namespace Jackett.Common.Utils
{
public abstract class FilterFunc
{
public static readonly FilterFuncExpression Expression;
public static readonly FilterFuncComponent Tag = Component("tag", args =>
{
var tag = args.ToLowerInvariant();
return indexer => Array.IndexOf(indexer.Tags, tag) > -1;
});
public static readonly FilterFuncComponent Language = Component("lang", args => indexer => indexer.Language.StartsWith(args, StringComparison.InvariantCultureIgnoreCase));
public static readonly FilterFuncComponent Type = Component("type", args => indexer => string.Equals(indexer.Type, args, StringComparison.InvariantCultureIgnoreCase));
static FilterFunc()
{
Expression = new FilterFuncExpression(Tag, Language, Type);
}
public static bool TryParse(string source, out Func<IIndexer, bool> func)
{
func = Expression.FromFilter(source);
return func != null;
}
public abstract Func<IIndexer, bool> FromFilter(string source);
public static FilterFuncComponent Component(string id, Func<string, Func<IIndexer, bool>> builder)
{
return new LambdaFilterFuncComponent(id, builder);
}
private class LambdaFilterFuncComponent : FilterFuncComponent
{
private readonly Func<string, Func<IIndexer, bool>> builder;
internal LambdaFilterFuncComponent(string id, Func<string, Func<IIndexer, bool>> builder) : base(id)
{
if (builder == null)
throw new ArgumentNullException(nameof(builder));
this.builder = builder;
}
public override Func<IIndexer, bool> ToFunc(string args)
{
var func = builder(args);
return indexer => indexer != null
? indexer.IsConfigured && func(indexer)
: throw new ArgumentNullException(nameof(indexer));
}
}
}
}

View File

@ -0,0 +1,46 @@
using System;
using System.Linq;
using Jackett.Common.Indexers;
namespace Jackett.Common.Utils.FilterFuncs
{
public abstract class FilterFuncComponent : FilterFunc
{
private static readonly char Separator = ':';
protected FilterFuncComponent(string id)
{
if (id == null)
throw new ArgumentNullException(nameof(id));
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("ID cannot be an empty string or whitespaces", nameof(id));
ID = id;
}
public string ID { get; }
public override Func<IIndexer, bool> FromFilter(string source)
{
if (string.IsNullOrWhiteSpace(source))
return null;
var parts = source.Split(new []{Separator}, 2);
if (parts.Length != 2)
return null;
if (!string.Equals(parts[0], ID, StringComparison.InvariantCultureIgnoreCase))
return null;
var args = parts[1];
if (string.IsNullOrWhiteSpace(args))
return null;
return ToFunc(args);
}
public abstract Func<IIndexer, bool> ToFunc(string args);
public string ToFilter(string args)
{
return $"{ID}{Separator}{args}";
}
}
}

View File

@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jackett.Common.Indexers;
namespace Jackett.Common.Utils.FilterFuncs
{
public class FilterFuncExpression : FilterFunc
{
private static readonly char Separator = ':';
private static readonly char NotOperator = '!';
private static readonly char OrOperator = ',';
private static readonly char AndOperator = '+';
private readonly IReadOnlyDictionary<string, Func<string, Func<IIndexer, bool>>> components;
public FilterFuncExpression(params FilterFuncComponent[] components)
{
if (components == null)
throw new ArgumentNullException(nameof(components));
if (components.Length == 0)
throw new ArgumentException("Filters cannot be an empty collection.", nameof(components));
if (components.Any(x => x == null))
throw new ArgumentException("Filters cannot contains null values.", nameof(components));
this.components = components.ToDictionary<FilterFuncComponent, string, Func<string, Func<IIndexer, bool>>>(x => x.ID, x => x.ToFunc, StringComparer.InvariantCultureIgnoreCase);
}
public override Func<IIndexer, bool> FromFilter(string source)
{
if (string.IsNullOrWhiteSpace(source))
return null;
if (source.Contains(OrOperator))
return source.Split(OrOperator).Select(FromFilter).Aggregate(Or);
if (source.Contains(AndOperator))
return source.Split(AndOperator).Select(FromFilter).Aggregate(And);
if (source[0] == NotOperator)
return Not(FromFilter(source.Substring(1)));
if (source.Contains(Separator))
{
var parts = source.Split(new[] {Separator}, 2);
if (parts.Length == 2 && components.TryGetValue(parts[0], out var toFunc))
return toFunc(parts[1]);
}
return null;
}
private static Func<IIndexer, bool> Not(Func<IIndexer, bool> u) => i => !u(i);
private static Func<IIndexer, bool> And(Func<IIndexer, bool> l, Func<IIndexer, bool> r) => i => l(i) && r(i);
private static Func<IIndexer, bool> Or(Func<IIndexer, bool> l, Func<IIndexer, bool> r) => i => l(i) || r(i);
}
}

View File

@ -212,7 +212,7 @@ namespace Jackett.Server.Controllers
var manualResult = new ManualSearchResult();
var trackers = CurrentIndexer is BaseMetaIndexer
? (CurrentIndexer as BaseMetaIndexer).Indexers.Where(t => t.IsConfigured)
? (CurrentIndexer as BaseMetaIndexer).ValidIndexers
: (new[] { CurrentIndexer });
// Filter current trackers list on Tracker query parameter if available

View File

@ -132,7 +132,7 @@ namespace Jackett.Server.Controllers
serverConfig.OmdbApiUrl = omdbApiUrl.TrimEnd('/');
configService.SaveConfig(serverConfig);
// HACK
indexerService.InitAggregateIndexer();
indexerService.InitMetaIndexers();
}
if (config.proxy_type != serverConfig.ProxyType ||

View File

@ -0,0 +1,98 @@
using System;
using Jackett.Common.Indexers;
using Jackett.Common.Utils.FilterFuncs;
using NUnit.Framework;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
[TestFixture]
public class FilterFuncComponentTests
{
private readonly FilterFuncComponent target = new FilterFuncComponentStub("filter");
private static readonly Func<IIndexer, bool> Func = _ => true;
private class FilterFuncComponentStub : FilterFuncComponent
{
public FilterFuncComponentStub(string id) : base(id)
{
}
public override Func<IIndexer, bool> ToFunc(string args) => Func;
}
[Test]
public void Ctor_NullID_ThrowsException()
{
Assert.Throws<ArgumentNullException>(() => new FilterFuncComponentStub(null));
}
[Test]
public void Ctor_EmptyID_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new FilterFuncComponentStub(string.Empty));
}
[Test]
public void Ctor_WhitespaceID_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new FilterFuncComponentStub(" "));
}
[Test]
public void FromFilter_NullSource_Null()
{
var actual = target.FromFilter(null);
Assert.IsNull(actual);
}
[Test]
public void FromFilter_EmptySource_Null()
{
var actual = target.FromFilter(string.Empty);
Assert.IsNull(actual);
}
[Test]
public void FromFilter_WhitespaceSource_Null()
{
var actual = target.FromFilter(" ");
Assert.IsNull(actual);
}
[Test]
public void FromFilter_WrongSource_Null()
{
var actual = target.FromFilter("wrong:args");
Assert.IsNull(actual);
}
[Test]
public void FromFilter_NoArgsSource_Null()
{
var actual = target.FromFilter(target.ID);
Assert.IsNull(actual);
}
[Test]
public void FromFilter_EmptyArgsSource_Null()
{
var actual = target.FromFilter($"{target.ID}:");
Assert.IsNull(actual);
}
[Test]
public void FromFilter_SourceWithArgs()
{
var actual = target.FromFilter($"{target.ID.ToUpper()}:args");
Assert.AreSame(Func, actual);
}
[Test]
public void FromFilter_CaseInsensitivePrefixSource()
{
var actual = target.FromFilter($"{target.ID.ToUpper()}:args");
Assert.AreSame(Func, actual);
}
}
}

View File

@ -0,0 +1,128 @@
using System;
using Jackett.Common.Indexers;
using Jackett.Common.Utils.FilterFuncs;
using Jackett.Test.TestHelpers;
using NUnit.Framework;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
[TestFixture]
public class FilterFuncExpressionTests
{
private class FilterFuncComponentStub : FilterFuncComponent
{
private readonly Func<string, Func<IIndexer, bool>> builderFunc;
public FilterFuncComponentStub(string id, Func<string, Func<IIndexer, bool>> builderFunc) : base(id)
{
this.builderFunc = builderFunc;
}
public override Func<IIndexer, bool> ToFunc(string args) => builderFunc(args);
}
private static readonly FilterFuncComponentStub _BoolFilterFunc =
new FilterFuncComponentStub("bool",
args => bool.Parse(args) ? (Func<IIndexer, bool>)(_ => true) : _ => false
);
[Test]
public void Ctor_NoFilters_ThrowsException()
{
Assert.Throws<ArgumentException>(() => new FilterFuncExpression());
}
[Test]
public void Ctor_NullFilters_ThrowsException()
{
Assert.Throws<ArgumentNullException>(() => new FilterFuncExpression(null));
}
[Test]
public void Ctor_EmptyFilters_ThrowsException()
{
Assert.Throws<ArgumentException>(() =>
new FilterFuncExpression(Array.Empty<FilterFuncComponent>())
);
}
[Test]
public void Ctor_WithNullFilter_ThrowsException()
{
Assert.Throws<ArgumentException>(() =>
new FilterFuncExpression(default(FilterFuncComponent))
);
}
[Test]
public void Ctor_WithDuplicatedPrefixFilter_ThrowsException()
{
const string id = "f1";
Func<string, Func<IIndexer, bool>> func = _ => throw TestExceptions.UnexpectedInvocation;
Assert.Throws<ArgumentException>(() =>
{
new FilterFuncExpression(
new FilterFuncComponentStub(id, func),
new FilterFuncComponentStub(id, func));
});
}
[Test]
public void SingleSource()
{
Func<IIndexer, bool> expectedFunc1 = _ => throw TestExceptions.UnexpectedInvocation;
Func<IIndexer, bool> expectedFunc2 = _ => throw TestExceptions.UnexpectedInvocation;
var target = new FilterFuncExpression(
new FilterFuncComponentStub("f1", _ => expectedFunc1),
new FilterFuncComponentStub("f2", _ => expectedFunc2)
);
var actualFunc1 = target.FromFilter("f1:args");
Assert.AreSame(expectedFunc1, actualFunc1);
var actualFunc2 = target.FromFilter("f2:args");
Assert.AreSame(expectedFunc2, actualFunc2);
var actualFunc3 = target.FromFilter("f3:args");
Assert.IsNull(actualFunc3);
}
[Test]
public void SingleSource_NotOperator()
{
var target = new FilterFuncExpression(_BoolFilterFunc);
var filterFunc = target.FromFilter("!bool:true");
Assert.IsFalse(filterFunc(null));
}
[Test]
public void SingleSource_AndOperator()
{
var target = new FilterFuncExpression(_BoolFilterFunc);
var filterFunc = target.FromFilter("bool:true+bool:false");
Assert.IsFalse(filterFunc(null));
}
[Test]
public void SingleSource_OrOperator()
{
var target = new FilterFuncExpression(_BoolFilterFunc);
var filterFunc = target.FromFilter("bool:false,bool:true");
Assert.IsTrue(filterFunc(null));
}
[Test]
public void SingleSource_OperatorPrecedence()
{
var target = new FilterFuncExpression(_BoolFilterFunc);
var filterFunc1 = target.FromFilter("bool:false+bool:true,bool:true");
Assert.IsTrue(filterFunc1(null));
var filterFunc2 = target.FromFilter("bool:true,bool:true+bool:false");
Assert.IsTrue(filterFunc2(null));
}
}
}

View File

@ -0,0 +1,55 @@
using System.Text;
using System.Threading.Tasks;
using Jackett.Common.Indexers;
using Jackett.Common.Models;
using Jackett.Common.Models.IndexerConfig;
using Jackett.Test.TestHelpers;
using Newtonsoft.Json.Linq;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
public class IndexerStub : IIndexer
{
public virtual string SiteLink => throw TestExceptions.UnexpectedInvocation;
public virtual string[] AlternativeSiteLinks => throw TestExceptions.UnexpectedInvocation;
public virtual string DisplayName => throw TestExceptions.UnexpectedInvocation;
public virtual string DisplayDescription => throw TestExceptions.UnexpectedInvocation;
public virtual string Type => throw TestExceptions.UnexpectedInvocation;
public virtual string Language => throw TestExceptions.UnexpectedInvocation;
public virtual string LastError
{
get => throw TestExceptions.UnexpectedInvocation;
set => throw TestExceptions.UnexpectedInvocation;
}
public virtual string Id => throw TestExceptions.UnexpectedInvocation;
public virtual Encoding Encoding => throw TestExceptions.UnexpectedInvocation;
public virtual TorznabCapabilities TorznabCaps => throw TestExceptions.UnexpectedInvocation;
public virtual bool IsConfigured => throw TestExceptions.UnexpectedInvocation;
public virtual string[] Tags => throw TestExceptions.UnexpectedInvocation;
public virtual Task<ConfigurationData> GetConfigurationForSetup() => throw TestExceptions.UnexpectedInvocation;
public virtual Task<IndexerConfigurationStatus> ApplyConfiguration(JToken configJson) => throw TestExceptions.UnexpectedInvocation;
public virtual void LoadFromSavedConfiguration(JToken jsonConfig) => throw TestExceptions.UnexpectedInvocation;
public virtual void SaveConfig() => throw TestExceptions.UnexpectedInvocation;
public virtual void Unconfigure() => throw TestExceptions.UnexpectedInvocation;
public virtual Task<IndexerResult> ResultsForQuery(TorznabQuery query, bool isMetaIndexer = false) => throw TestExceptions.UnexpectedInvocation;
public virtual bool CanHandleQuery(TorznabQuery query) => throw TestExceptions.UnexpectedInvocation;
}
}

View File

@ -0,0 +1,55 @@
using NUnit.Framework;
using static Jackett.Common.Utils.FilterFunc;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
[TestFixture]
public class LanguageFuncTests
{
private class LanguageIndexerStub : IndexerStub
{
public LanguageIndexerStub(string language)
{
Language = language;
}
public override bool IsConfigured => true;
public override string Language { get; }
}
[Test]
public void CaseInsensitiveSource_CaseInsensitiveFilter()
{
var language = "en";
var region = "US";
var lrLanguage = new LanguageIndexerStub($"{language.ToLower()}-{region.ToLower()}");
var LRFilterFunc = Language.ToFunc($"{language.ToUpper()}-{region.ToUpper()}");
Assert.IsTrue(LRFilterFunc(lrLanguage));
var lRLanguage = new LanguageIndexerStub($"{language.ToLower()}-{region.ToUpper()}");
var LrFilterFunc = Language.ToFunc($"{language.ToUpper()}-{region.ToLower()}");
Assert.IsTrue(LrFilterFunc(lRLanguage));
var LrLanguage = new LanguageIndexerStub($"{language.ToUpper()}-{region.ToLower()}");
var lRFilterFunc = Language.ToFunc($"{language.ToLower()}-{region.ToUpper()}");
Assert.IsTrue(lRFilterFunc(LrLanguage));
var LRLanguage = new LanguageIndexerStub($"{language.ToUpper()}-{region.ToUpper()}");
var lrFilterFunc = Language.ToFunc($"{language.ToLower()}-{region.ToLower()}");
Assert.IsTrue(lrFilterFunc(LRLanguage));
}
[Test]
public void LanguageWithoutRegion()
{
var language = "en";
var funcFilter = Language.ToFunc(language);
Assert.IsTrue(funcFilter(new LanguageIndexerStub(language)));
Assert.IsTrue(funcFilter(new LanguageIndexerStub($"{language}-region1")));
Assert.IsFalse(funcFilter(new LanguageIndexerStub($"language2-{language}")));
}
}
}

View File

@ -0,0 +1,47 @@
using NUnit.Framework;
using static Jackett.Common.Utils.FilterFunc;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
[TestFixture]
public class TagFuncTests
{
private class TagsIndexerStub : IndexerStub
{
public TagsIndexerStub(params string[] tags)
{
Tags = tags;
}
public override bool IsConfigured => true;
public override string[] Tags { get; }
}
[Test]
public void CaseInsensitiveFilter()
{
var tagId = "g1";
var tag = new TagsIndexerStub(tagId);
var upperTarget = Tag.ToFunc(tagId.ToUpper());
Assert.IsTrue(upperTarget(tag));
var lowerTarget = Tag.ToFunc(tagId.ToLower());
Assert.IsTrue(lowerTarget(tag));
}
[Test]
public void ContainsTagId()
{
var tagId = "g1";
var target = Tag.ToFunc(tagId);
Assert.IsTrue(target(new TagsIndexerStub(tagId)));
Assert.IsTrue(target(new TagsIndexerStub(tagId, "g2")));
Assert.IsTrue(target(new TagsIndexerStub("g2", tagId)));
Assert.IsFalse(target(new TagsIndexerStub("g2")));
}
}
}

View File

@ -0,0 +1,52 @@
using NUnit.Framework;
using static Jackett.Common.Utils.FilterFunc;
namespace Jackett.Test.Common.Utils.FilterFuncs
{
[TestFixture]
public class TypeFuncTests
{
private class TypeIndexerStub : IndexerStub
{
public TypeIndexerStub(string type)
{
Type = type;
}
public override bool IsConfigured => true;
public override string Type { get; }
}
[Test]
public void CaseInsensitiveSource_CaseInsensitiveFilter()
{
var typeId = "type-id";
var lowerType = new TypeIndexerStub(typeId.ToLower());
var upperType = new TypeIndexerStub(typeId.ToUpper());
var upperFilterFunc = Type.ToFunc(typeId.ToUpper());
Assert.IsTrue(upperFilterFunc(lowerType));
Assert.IsTrue(upperFilterFunc(upperType));
var lowerFilterFunc = Type.ToFunc(typeId.ToLower());
Assert.IsTrue(lowerFilterFunc(lowerType));
Assert.IsTrue(lowerFilterFunc(upperType));
}
[Test]
public void PartialType()
{
var typeId = "type-id";
var funcFilter = Type.ToFunc($"{typeId}");
Assert.IsFalse(funcFilter(new TypeIndexerStub($"{typeId}suffix")));
Assert.IsFalse(funcFilter(new TypeIndexerStub($"prefix{typeId}")));
Assert.IsFalse(funcFilter(new TypeIndexerStub($"prefix{typeId}suffix")));
}
}
}

View File

@ -0,0 +1,9 @@
using NUnit.Framework;
namespace Jackett.Test.TestHelpers
{
internal static class TestExceptions
{
public static AssertionException UnexpectedInvocation => new AssertionException("Unexpected Invocation");
}
}

View File

@ -25,6 +25,6 @@ namespace Jackett.Test.TestHelpers
public Task TestIndexer(string name) => throw new NotImplementedException();
public void InitAggregateIndexer() => throw new NotImplementedException();
public void InitMetaIndexers() => throw new NotImplementedException();
}
}