From ac8e28e52e56ec9448a6875636ef21d167ac6c8f Mon Sep 17 00:00:00 2001 From: TheOtherP Date: Tue, 12 Nov 2024 21:29:23 +0100 Subject: [PATCH] Added some more infos in the notifications section See #660 --- core/src/main/resources/changelog.yaml | 2 + core/src/main/resources/static/js/nzbhydra.js | 14153 ++++++++-------- .../main/resources/static/js/nzbhydra.js.map | 2 +- .../ui-src/js/config/config-fields-service.js | 3 +- 4 files changed, 7082 insertions(+), 7078 deletions(-) diff --git a/core/src/main/resources/changelog.yaml b/core/src/main/resources/changelog.yaml index e8d4a2bcd..a18aa6886 100644 --- a/core/src/main/resources/changelog.yaml +++ b/core/src/main/resources/changelog.yaml @@ -4,6 +4,8 @@ changes: - type: "feature" text: "Previously the way forbidden words would be excluded from queries depended on the backend and, to a certain degree, on the indexer but in some cases -- would be used as a prefix which is only supported by many indexers. NZBHydra will now attempt to automatically detect if and how certain words may be excluded from a query. This will either happen during a caps check or on the first search request where needed." + - type: "note" + text: "Added some more infos in the notifications section. Thanks, @pikeas. See #660" final: true - version: "v7.9.0" date: "2024-11-10" diff --git a/core/src/main/resources/static/js/nzbhydra.js b/core/src/main/resources/static/js/nzbhydra.js index e02fbf340..2d9ffcd84 100644 --- a/core/src/main/resources/static/js/nzbhydra.js +++ b/core/src/main/resources/static/js/nzbhydra.js @@ -942,1712 +942,1060 @@ nzbhydraapp.directive('eventFocus', ["focus", function (focus) { * limitations under the License. */ -CheckCapsModalInstanceCtrl.$inject = ["$scope", "$interval", "$http", "$timeout", "growl", "capsCheckRequest"]; -IndexerConfigBoxService.$inject = ["$http", "$q", "$uibModal"]; -IndexerCheckBeforeCloseService.$inject = ["$q", "ModalService", "IndexerConfigBoxService", "growl", "blockUI"]; -function regexValidator(regex, message, prefixViewValue, preventEmpty) { +angular + .module('nzbhydraApp') + .directive('hydraTasks', hydraTasks); + +function hydraTasks() { + controller.$inject = ["$scope", "$http"]; return { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - if (value) { - if (Array.isArray(value)) { - for (var i = 0; i < value.length; i++) { - if (!regex.test(value[i])) { - return false; - } - } - return true; - } else { - return regex.test(value); - } - } - return !preventEmpty; - }, - message: (prefixViewValue ? '$viewValue + " ' : '" ') + message + '"' + templateUrl: 'static/html/directives/tasks.html', + controller: controller }; -} -function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) { - var fieldset = []; - if (indexerModel.searchModuleType === "TORZNAB") { - fieldset.push({ - type: 'help', - templateOptions: { - type: 'help', - lines: ["Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api"] - } + function controller($scope, $http) { + + $http.get("internalapi/tasks").then(function (response) { + $scope.tasks = response.data; }); - } - if ((indexerModel.searchModuleType === "NEWZNAB" || indexerModel.searchModuleType === "TORZNAB") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { - var message; - var cssClass; - if (!indexerModel.configComplete) { - message = "The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration."; - cssClass = "alert alert-danger"; - } else { - message = "The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable."; - cssClass = "alert alert-warning"; + + $scope.runTask = function (taskName) { + $http.put("internalapi/tasks/" + taskName).then(function (response) { + $scope.tasks = response.data; + }); } - fieldset.push({ - type: 'help', - hideExpression: 'model.allCapsChecked && model.configComplete', - templateOptions: { - type: 'help', - lines: [message], - class: cssClass - } - }); } +} - var stateHelp = ""; - if (indexerModel.state === "DISABLED_SYSTEM_TEMPORARY" || indexerModel.state === "DISABLED_SYSTEM") { - if (indexerModel.state === "DISABLED_SYSTEM_TEMPORARY") { - stateHelp = "The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually"; - } else { - stateHelp = "The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens."; + +angular + .module('nzbhydraApp') + .directive('tabOrChart', tabOrChart); + +function tabOrChart() { + return { + templateUrl: 'static/html/directives/tab-or-chart.html', + transclude: { + "chartSlot": "chart", + "tableSlot": "table" + }, + restrict: 'E', + replace: true, + scope: { + display: "@" } - } - if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') { - fieldset.push( - { - key: 'name', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Name', - required: true - }, - validators: { - uniqueName: { - expression: function (viewValue) { - if (isInitial || viewValue !== indexerModel.name) { - return _.pluck(parentModel, "name").indexOf(viewValue) === -1; - } - return true; - }, - message: '"Indexer \\"" + $viewValue + "\\" already exists"' - }, - noComma: - { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - if (value) { - return value.indexOf(",") === -1; - } - return true; - }, - message: '"Name may not contain a comma"' - } - } - }) - } + }; - if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') { - fieldset.push({ - key: 'state', - type: 'horizontalIndexerStateSwitch', - templateOptions: { - type: 'switch', - label: 'State', - help: stateHelp - } - }); - } +} - if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) { - var hostField = { - key: 'host', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Host', - required: true, - placeholder: 'http://www.someindexer.com' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - }; - if (indexerModel.searchModuleType === 'TORZNAB') { - hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one'; +angular + .module('nzbhydraApp') + .directive('selectionButton', selectionButton); + +function selectionButton() { + controller.$inject = ["$scope"]; + return { + templateUrl: 'static/html/directives/selection-button.html', + scope: { + selected: "=", + selectable: "=", + invertSelection: "<", + selectAll: "<", + deselectAll: "<", + btn: "@" + }, + controller: controller + }; + + function controller($scope) { + + if (angular.isUndefined($scope.btn)) { + $scope.btn = "default"; //Will form class "btn-default" + } + + if (angular.isUndefined($scope.invertSelection)) { + $scope.invertSelection = function () { + $scope.selected = _.difference($scope.selectable, $scope.selected); + }; + } + + if (angular.isUndefined($scope.selectAll)) { + $scope.selectAll = function () { + $scope.selected.push.apply($scope.selected, $scope.selectable); + }; + } + + if (angular.isUndefined($scope.deselectAll)) { + $scope.deselectAll = function () { + $scope.selected.splice(0, $scope.selected.length); + }; } - fieldset.push( - hostField - ); - } - if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') { - fieldset.push( - { - key: 'apiKey', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'API Key' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - } - ) - } - if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { - fieldset.push( - { - key: 'apiPath', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'API path', - help: 'Path to the API. If empty /api is used', - required: false, - advanced: true - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - } - ) } +} - if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) { - fieldset.push( - { - key: 'username', - type: 'horizontalInput', - templateOptions: { - type: 'text', - required: false, - label: 'Username', - help: 'Only needed if indexer requires HTTP auth for API access (rare).' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } + + +NfoModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "nfo"];angular + .module('nzbhydraApp') + .directive('searchResult', searchResult); + +function searchResult() { + controller.$inject = ["$scope", "$element", "$http", "growl", "$attrs", "$uibModal", "$window", "DebugService", "localStorageService", "HydraAuthService", "ConfigService"]; + return { + templateUrl: 'static/html/directives/search-result.html', + require: '^result', + replace: false, + scope: { + result: "<", + searchResultsControllerShared: "<" + }, + controller: controller + }; + + + function handleDisplay($scope, localStorageService, ConfigService) { + //Display state / expansion + $scope.foo.duplicatesDisplayed = localStorageService.get("duplicatesDisplayed") !== null ? localStorageService.get("duplicatesDisplayed") : false; + $scope.foo.showCovers = localStorageService.get("showCovers") !== null ? localStorageService.get("showCovers") : true; + $scope.foo.alwaysShowTitles = localStorageService.get("alwaysShowTitles") !== null ? localStorageService.get("alwaysShowTitles") : true; + $scope.duplicatesExpanded = false; + $scope.titlesExpanded = $scope.searchResultsControllerShared.expandGroupsByDefault; + $scope.coverSize = ConfigService.getSafe().searching.coverSize; + + function calculateDisplayState() { + $scope.resultDisplayed = ($scope.result.titleGroupIndex === 0 || $scope.titlesExpanded) && ($scope.duplicatesExpanded || $scope.result.duplicateGroupIndex === 0); + } + + calculateDisplayState(); + + $scope.toggleTitleExpansion = function () { + $scope.titlesExpanded = !$scope.titlesExpanded; + $scope.$emit("toggleTitleExpansionUp", $scope.titlesExpanded, $scope.result.titleGroupIndicator); + }; + + $scope.toggleDuplicateExpansion = function () { + $scope.duplicatesExpanded = !$scope.duplicatesExpanded; + $scope.$emit("toggleDuplicateExpansionUp", $scope.duplicatesExpanded, $scope.result.hash); + }; + + $scope.$on("toggleTitleExpansionDown", function ($event, value, titleGroupIndicator) { + if ($scope.result.titleGroupIndicator === titleGroupIndicator) { + $scope.titlesExpanded = value; + calculateDisplayState(); } - ); - } + }); - if ('WTFNZB' === indexerModel.searchModuleType) { - fieldset.push( - { - key: 'username', - type: 'horizontalInput', - templateOptions: { - type: 'text', - required: true, - label: 'Username', - help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } + $scope.$on("toggleDuplicateExpansionDown", function ($event, value, hash) { + if ($scope.result.hash === hash) { + $scope.duplicatesExpanded = value; + calculateDisplayState(); } - ); - fieldset.push( - { - key: 'password', - type: 'passwordSwitch', - hideExpression: '!model.username', - templateOptions: { - type: 'text', - required: false, - label: 'Password', - help: 'Only needed if indexer requires HTTP auth for API access (rare).' - } + }); + + $scope.$on("toggleShowCovers", function ($event, value) { + $scope.foo.showCovers = value; + }); + + $scope.$on("toggleAlwaysShowTitles", function ($event, value) { + $scope.foo.alwaysShowTitles = value; + console.log("alwaysShowTitles: " + alwaysShowTitles); + }); + + $scope.$on("duplicatesDisplayed", function ($event, value) { + $scope.foo.duplicatesDisplayed = value; + if (!value) { + //Collapse duplicate groups they shouldn't be displayed + $scope.duplicatesExpanded = false; } - ) + calculateDisplayState(); + }); + + $scope.$on("calculateDisplayState", function () { + calculateDisplayState(); + }); } + function handleSelection($scope, $element) { + $scope.foo.selected = false; - if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') { - fieldset.push( - { - key: 'score', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Priority', - required: true, - help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.', - tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name).
The result from the indexer with the highest number is shown first in the GUI and returned for API searches.' + function sendSelectionEvent(isSelected) { + $scope.$emit("selectionUp", $scope.result, isSelected); + } - } - }); - } + $scope.clickCheckbox = function (event, result) { + var isSelected = event.currentTarget.checked; + sendSelectionEvent(isSelected); + $scope.$emit("checkboxClicked", event, isSelected, event.currentTarget); + }; - fieldset.push( - { - key: 'timeout', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Timeout', - min: 1, - help: 'Supercedes the general timeout in "Searching".', - advanced: true + function isBetween(num, betweena, betweenb) { + return (betweena <= num && num <= betweenb) || (betweena >= num && num >= betweenb); + } + + $scope.$on("shiftClick", function (event, newValue, previousClickTargetElement, newClickTargetElement) { + //Parent needs to be the td, between checkbox and td are two divs + var fromYlocation = $(previousClickTargetElement).parent().parent().parent().prop("offsetTop"); + var newYlocation = $(newClickTargetElement).parent().parent().parent().prop("offsetTop"); + var elementYlocation = $($element).prop("offsetTop"); + if (!$scope.resultDisplayed) { + return; } - }, - { - key: 'schedule', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Schedule', - help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.', - advanced: true + + if (isBetween(elementYlocation, fromYlocation, newYlocation)) { + sendSelectionEvent(newValue); + $scope.foo.selected = newValue === 1; } - } - ); + }); - if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { - fieldset.push( - { - key: 'hitLimit', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'API hit limit', - help: 'Maximum number of API hits since "API hit reset time".', - tooltip: 'When the maximum number of API hits is reached the indexer isn\'t used anymore. Only API hits done by NZBHydra are taken into account.' - }, - validators: { - greaterThanZero: { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - return _.isNullOrEmpty(value) || value > 0; - }, - message: '"Value must be greater than 0"' + $scope.$on("invertSelection", function () { + if (!$scope.resultDisplayed) { + return; + } + $scope.foo.selected = !$scope.foo.selected; + sendSelectionEvent($scope.foo.selected); + }); + + $scope.$on("deselectAll", function () { + if (!$scope.resultDisplayed) { + return; + } + $scope.foo.selected = false; + sendSelectionEvent($scope.foo.selected); + }); + + $scope.$on("selectAll", function () { + if (!$scope.resultDisplayed) { + return; + } + $scope.foo.selected = true; + + sendSelectionEvent($scope.foo.selected); + }); + + $scope.$on("toggleSelection", function ($event, result, value) { + if (!$scope.resultDisplayed || result !== $scope.result) { + return; + } + $scope.foo.selected = value; + }); + } + + function handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService) { + $scope.showDetailsDl = HydraAuthService.getUserInfos().maySeeDetailsDl; + + $scope.showNfo = showNfo; + + function showNfo(resultItem) { + if (resultItem.has_nfo === 0) { + return; + } + var uri = new URI("internalapi/nfo/" + resultItem.searchResultId); + return $http.get(uri.toString()).then(function (response) { + if (response.data.successful) { + if (response.data.hasNfo) { + $scope.openModal("lg", response.data.content) + } else { + growl.info("No NFO available"); } + } else { + growl.error(response.data.content); } - }, - { - key: 'downloadLimit', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Download limit', - help: 'When # of downloads since "Hit reset time" is reached indexer will not be searched.' - }, - validators: { - greaterThanZero: { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - return _.isNullOrEmpty(value) || value > 0; - }, - message: '"Value must be greater than 0"' - } - } - } - ); - fieldset.push( - { - key: 'hitLimitResetTime', - type: 'horizontalInput', - hideExpression: '!model.hitLimit && !model.downloadLimit', - templateOptions: { - type: 'number', - label: 'Hit reset time', - help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.', - tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.' - }, - validators: { - timeOfDay: { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - return value >= 0 && value <= 23; - }, - message: '$viewValue + " is not a valid hour of day (0-23)"' - } - } - }, - { - key: 'loadLimitOnRandom', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Load limiting', - help: 'If set indexer will only be picked for one out of x API searches (on average).', - tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.', - advanced: true - }, - validators: { - greaterThanZero: { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - return _.isNullOrEmpty(value) || value > 1; - }, - message: '"Value must be greater than 1"' + }); + } + + $scope.openModal = openModal; + + function openModal(size, nfo) { + var modalInstance = $uibModal.open({ + template: '
', + controller: NfoModalInstanceCtrl, + size: size, + resolve: { + nfo: function () { + return nfo; } } - } - ); - } - if (indexerModel.searchModuleType === 'TORZNAB') { - fieldset.push({ - key: 'minSeeders', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Minimum # seeders', - help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.' - } - }) - } + }); - if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) { - fieldset.push( - { - key: 'userAgent', - type: 'horizontalInput', - templateOptions: { - type: 'text', - required: false, - label: 'User agent', - help: 'Rarely needed. Will supercede the one in the main searching settings.', - advanced: true - } - } - ) - } + modalInstance.result.then(); + } - if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { - fieldset.push( - { - key: 'customParameters', - type: 'horizontalChips', - templateOptions: { - 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.', - advanced: 'true' - } + $scope.getNfoTooltip = function () { + if ($scope.result.hasNfo === "YES") { + return "Show NFO" + } else if ($scope.result.hasNfo === "MAYBE") { + return "Try to load NFO (may not be available)"; + } else { + return "No NFO available"; } - ) + }; } - fieldset.push( - { - key: 'preselect', - type: 'horizontalSwitch', - hideExpression: 'model.enabledForSearchSource==="EXTERNAL"', - templateOptions: { - type: 'switch', - label: 'Preselect', - help: 'Preselect this indexer on the search page.' - } - } - ); - fieldset.push( - { - key: 'enabledForSearchSource', - type: 'horizontalSelect', - templateOptions: { - label: 'Enable for...', - options: [ - {name: 'Internal searches only', value: 'INTERNAL'}, - {name: 'API searches only', value: 'API'}, - {name: 'All but API update queries ', value: 'ALL_BUT_RSS'}, - {name: 'Only API update queries ', value: 'ONLY_RSS'}, - {name: 'Internal and any API searches', value: 'BOTH'} - ], - help: 'Select for which searches this indexer will be used. "Update queries" are searches without query or ID (e.g. done by Sonarr periodically).', - advanced: true - } - } - ); + function handleNzbDownload($scope, $window) { + $scope.downloadNzb = downloadNzb; - fieldset.push( - { - key: 'color', - type: 'colorInput', - templateOptions: { - label: 'Color', - help: 'If set it will be used in the search results to mark the indexer\'s results.', - tooltip: 'To mark expanded results they\'re shown in a darker shade so it\'s recommended to use indexer colors which not only differ in lightness', - advanced: true - } + function downloadNzb(resultItem) { + //href = "{{ result.link }}" + $window.location.href = resultItem.link; } - ); + } - fieldset.push( - { - key: 'vipExpirationDate', - type: 'horizontalInput', - templateOptions: { - required: false, - label: 'VIP expiry', - help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or "Lifetime".' - }, - validators: { - port: regexValidator(/^(\d{4}-\d{2}-\d{2})|Lifetime$/, "is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')", true, false) - } - } - ); - if (indexerModel.searchModuleType !== "ANIZB" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { - var cats = CategoriesService.getWithoutAll(); - var options = _.map(cats, function (x) { - return {id: x.name, label: x.name} - }); - fieldset.push( - { - key: 'enabledCategories', - type: 'horizontalMultiselect', - templateOptions: { - label: 'Categories', - help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.', - options: options, - settings: { - showSelectedValues: false, - noSelectedText: "None/All" - }, - advanced: true + function controller($scope, $element, $http, growl, $attrs, $uibModal, $window, DebugService, localStorageService, HydraAuthService, ConfigService) { + $scope.foo = {}; + handleDisplay($scope, localStorageService, ConfigService); + handleSelection($scope, $element); + handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService); + handleNzbDownload($scope, $window); + + $scope.kify = function () { + return function (number) { + if (number > 1000) { + return Math.round(number / 1000) + "k"; } - } - ); + return number; + }; + }; + + + $scope.showCover = function (url) { + console.log("Show " + url); + $uibModal.open({ + template: '', + controller: ["$scope", "url", function ($scope, url) { + $scope.url = url; + }], + resolve: { + url: function () { + return url; + } + }, + size: "md", + keyboard: true, + windowTopClass: 'cover-modal-dialog' + }); + }; + } +} +angular + .module('nzbhydraApp') + .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl); - if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { - fieldset.push( - { - key: 'supportedSearchIds', - type: 'horizontalMultiselect', - templateOptions: { - label: 'Search IDs', - options: [ - {label: 'IMDB (TV)', id: 'TVIMDB'}, - {label: 'TVDB', id: 'TVDB'}, - {label: 'TVRage', id: 'TVRAGE'}, - {label: 'Trakt', id: 'TRAKT'}, - {label: 'TVMaze', id: 'TVMAZE'}, - {label: 'IMDB', id: 'IMDB'}, - {label: 'TMDB', id: 'TMDB'} - ], - noSelectedText: "None", - advanced: true - } - } - ); - fieldset.push( - { - key: 'supportedSearchTypes', - type: 'horizontalMultiselect', - templateOptions: { - label: 'Search types', - options: [ - {label: 'Audio', id: 'AUDIO'}, - {label: 'Ebooks', id: 'BOOK'}, - {label: 'Movies', id: 'MOVIE'}, - {label: 'Search', id: 'SEARCH'}, - {label: 'TV', id: 'TVSEARCH'} - ], - buttonText: "None", - advanced: true - } - } - ); - fieldset.push( - { - type: 'horizontalCheckCaps', - hideExpression: '!model.host || !model.name', - templateOptions: { - label: 'Check capabilities', - help: 'Find out what search types and IDs the indexer supports.', - tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.' - } - } - ) - } +function NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) { - if (indexerModel.searchModuleType === 'NZBINDEX') { - fieldset.push( - { - key: 'generalMinSize', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Min size', - help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category' - } - } - ); - } + $scope.nfo = nfo; - if (indexerModel.searchModuleType === 'BINSEARCH') { - fieldset.push({ - key: 'binsearchOtherGroups', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Search in other groups', - help: 'If disabled binsearch will only search in the most popular usenet groups' - } - }) - } + $scope.ok = function () { + $uibModalInstance.close($scope.selected.item); + }; - return fieldset; + $scope.cancel = function () { + $uibModalInstance.dismiss(); + }; } -function _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) { - var modalInstance = $uibModal.open({ - templateUrl: 'static/html/config/indexer-config-box.html', - controller: 'IndexerConfigBoxInstanceController', - size: 'lg', - resolve: { - model: function () { - indexerModel.showAdvanced = parentModel.showAdvanced; - return indexerModel; - }, - fields: function () { - return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode); - }, - form: function () { - return form; - }, - isInitial: function () { - return isInitial - }, - parentModel: function () { - return parentModel; - } - , - info: function () { - return indexerModel.info; +angular + .module('nzbhydraApp') + .filter('kify', function () { + return function (number) { + if (number > 1000) { + return Math.round(number / 1000) + "k"; } + return number; } }); - - modalInstance.result.then(function (returnedModel) { - form.$setDirty(true); - if (angular.isDefined(callback)) { - callback(true, returnedModel); - } - }, function () { - if (angular.isDefined(callback)) { - callback(false); - } - }); -} - angular .module('nzbhydraApp') - .config(["formlyConfigProvider", function config(formlyConfigProvider) { + .directive('saveOrSendFile', saveOrSendFile); - formlyConfigProvider.setType({ - name: 'indexers', - templateUrl: 'static/html/config/indexer-config.html', - controller: function ($scope, $uibModal, growl, CategoriesService) { - $scope.showBox = showBox; - $scope.formOptions = {formState: $scope.formState}; - $scope.showPresetSelection = showPresetSelection; +function saveOrSendFile() { + controller.$inject = ["$scope", "$http", "growl", "ConfigService"]; + return { + templateUrl: 'static/html/directives/save-or-send-file.html', + scope: { + searchResultId: "<", + isFile: "<", + type: "<" + }, + controller: controller + }; - function showPresetSelection() { - $uibModal.open({ - templateUrl: 'static/html/config/indexer-config-selection.html', - controller: 'IndexerConfigSelectionBoxInstanceController', - size: 'lg', - resolve: { - model: function () { - return $scope.model; - }, - form: function () { - return $scope.form; - } - } - }); + function controller($scope, $http, growl, ConfigService) { + $scope.cssClass = "glyphicon-save-file"; + var endpoint; + if ($scope.type === "TORRENT") { + $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks; + $scope.tooltip = "Save torrent to black hole or send magnet link"; + endpoint = "internalapi/saveOrSendTorrent"; + } else { + $scope.tooltip = "Save NZB to black hole"; + $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo); + endpoint = "internalapi/saveNzbToBlackhole"; + } + $scope.add = function () { + $scope.cssClass = "nzb-spinning"; + $http.put(endpoint, $scope.searchResultId).then(function (response) { + if (response.data.successful) { + $scope.cssClass = "glyphicon-ok"; + } else { + $scope.cssClass = "glyphicon-remove"; + growl.error(response.data.message); } + }); + }; + } +} - //Called when clicking the box of an existing indexer - function showBox(indexerModel, model) { - _showBox(indexerModel, model, false, $uibModal, CategoriesService, "indexer", $scope.form) - } +//Can be used in an ng-repeat directive to call a function when the last element was rendered +//We use it to mark the end of sorting / filtering so we can stop blocking the UI - } - }); - }]); +onFinishRender.$inject = ["$timeout"]; +angular + .module('nzbhydraApp') + .directive('onFinishRender', onFinishRender); +function onFinishRender($timeout) { + function linkFunction(scope, element, attr) { -angular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$uibModal", "$http", "model", "form", "growl", "CategoriesService", "$timeout", "ModalService", "RequestsErrorHandler", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) { + if (scope.$last === true) { + console.log("Render finished"); + // console.timeEnd("Presenting"); + // console.timeEnd("searchall"); + scope.$emit("onFinishRender") + } + } - $scope.showBox = showBox; - $scope.isInitial = false; + return { + link: linkFunction + } +} +//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly +angular + .module('nzbhydraApp') + .directive('multiselectDropdown', - $scope.select = function (modelPreset) { + dropdownMultiselectDirective + ); - addEntry(modelPreset); - $timeout(function () { - $uibModalInstance.close(); - }, - 200); - }; +function dropdownMultiselectDirective() { + return { + scope: { + selectedModel: '=', + options: '=', + settings: '=?', + events: '=?' + }, + transclude: { + toggleDropdown: '?toggleDropdown' + }, + templateUrl: 'static/html/directives/multiselect-dropdown.html', + controller: ["$scope", "$element", "$filter", "$document", function dropdownMultiselectController($scope, $element, $filter, $document) { + var $dropdownTrigger = $element.children()[0]; - $scope.readJackettConfig = function () { - var indexerModel = createIndexerModel(); - indexerModel.searchModuleType = "JACKETT_CONFIG"; - indexerModel.isInitial = false; - indexerModel.host = "http://127.0.0.1:9117"; - indexerModel.name = "Jackett config"; - _showBox(indexerModel, model, true, $uibModal, CategoriesService, "jackettConfig", form, function (isSubmitted, returnedModel) { - if (isSubmitted) { - //User pushed button, now we read the config - RequestsErrorHandler.specificallyHandled(function () { - $http.post("internalapi/indexer/readJackettConfig", {existingIndexers: model, jackettConfig: returnedModel}, { - headers: { - "Accept": "application/json;charset=utf-8", - "Accept-Charset": "charset=utf-8" - } - }).then(function (response) { - //Replace model with new result - model.splice(0, model.length); - _.each(response.data.newIndexersConfig, function (x) { - model.push(x); - }); - growl.info("Added " + response.data.addedTrackers + " new trackers from Jackett"); - growl.info("Updated " + response.data.updatedTrackers + " trackers from Jackett"); + var settings = { + showSelectedValues: true, + showSelectAll: true, + showDeselectAll: true, + noSelectedText: 'None selected' + }; + var events = { + onToggleItem: angular.noop + }; + angular.extend(events, $scope.events || []); + angular.extend(settings, $scope.settings || []); + angular.extend($scope, {settings: settings, events: events}); - }, function (response) { - ModalService.open("Error reading jackett config", response.data, {}, "md", "left"); - }); - }); + $scope.buttonText = ""; + if (settings.buttonText) { + $scope.buttonText = settings.buttonText; + } else { + $scope.$watch("selectedModel", function () { + if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) { + if ($scope.selectedModel.length === 0) { + if ($scope.settings.noSelectedText) { + $scope.buttonText = $scope.settings.noSelectedText; + } else { + $scope.buttonText = "None selected"; + } + } else if ($scope.selectedModel.length === $scope.options.length) { + $scope.buttonText = "All selected"; + } else { + var selected = []; + _.each($scope.options, function (x) { + if ($scope.selectedModel.indexOf(x.id) > -1) { + selected.push(x.label); + } + }) + $scope.buttonText = selected.join(", "); + } + } else { + if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) { + $scope.buttonText = $scope.settings.noSelectedText; + } else { + $scope.buttonText = $scope.selectedModel.length + " / " + $scope.options.length + " selected"; + } + } + }, true); } - }); + $scope.open = false; - $timeout(function () { - $uibModalInstance.close(); - }, - 200); - }; + $scope.toggleDropdown = function () { + $scope.open = !$scope.open; + }; - function showBox(indexerModel, model) { - _showBox(indexerModel, model, false, $uibModal, CategoriesService, "indexer", form) - } + $scope.toggleItem = function (option) { + var index = $scope.selectedModel.indexOf(option.id); + var oldValue = index > -1; + if (oldValue) { + $scope.selectedModel.splice(index, 1); + } else { + $scope.selectedModel.push(option.id); + } + $scope.events.onToggleItem(option, !oldValue); + }; - function createIndexerModel() { - return angular.copy({ - allCapsChecked: false, - apiKey: null, - backend: 'NEWZNAB', - color: null, - configComplete: false, - categoryMapping: null, - downloadLimit: null, - enabledCategories: [], - enabledForSearchSource: "BOTH", - generalMinSize: null, - hitLimit: null, - hitLimitResetTime: 0, - host: null, - loadLimitOnRandom: null, - name: null, - password: null, - preselect: true, - score: 0, - searchModuleType: 'NEWZNAB', - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: undefined, - supportedSearchTypes: undefined, - timeout: null, - username: null, - userAgent: null - }); - } + $scope.selectAll = function () { + $scope.selectedModel = _.pluck($scope.options, "id"); + }; - function addEntry(preset) { - if (checkAddingAllowed(model, preset)) { - var indexerModel = createIndexerModel(); - if (angular.isDefined(preset)) { - _.extend(indexerModel, preset); - } + $scope.deselectAll = function () { + $scope.selectedModel.splice(0, $scope.selectedModel.length); + }; - $scope.isInitial = true; + //Close when clicked outside - _showBox(indexerModel, model, true, $uibModal, CategoriesService, "indexer", form, function (isSubmitted, returnedModel) { - if (isSubmitted) { - //Here is where the entry is actually added to the model - model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel); + $document.on('click', function (e) { + function contains(collection, target) { + var containsTarget = false; + collection.some(function (object) { + if (object === target) { + containsTarget = true; + return true; + } + return false; + }); + return containsTarget; + } + + if ($scope.open) { + var target = e.target.parentElement; + var parentFound = false; + + while (angular.isDefined(target) && target !== null && !parentFound) { + if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) { + if (target === $dropdownTrigger) { + parentFound = true; + } + } + target = target.parentElement; + } + + if (!parentFound) { + $scope.$apply(function () { + $scope.open = false; + }); + } } }); - } else { - growl.error("That predefined indexer is already configured."); //For now this is the only case where adding is forbidden so we use this hardcoded message "for now"... (;-)) - } + + + }] + } +} +angular + .module('nzbhydraApp').directive("keepFocus", ['$timeout', function ($timeout) { + /* + Intended use: + + */ + return { + restrict: 'A', + require: 'ngModel', + link: function ($scope, $element, attrs, ngModel) { + + ngModel.$parsers.unshift(function (value) { + $timeout(function () { + $element[0].focus(); + }); + return value; + }); - function checkAddingAllowed(existingIndexers, preset) { - if (!preset || !(preset.searchModuleType === "ANIZB" || preset.searchModuleType === "BINSEARCH" || preset.searchModuleType === "NZBINDEX" || preset.searchModuleType === "NZBCLUB")) { - return true; } - return !_.any(existingIndexers, function (existingEntry) { - return existingEntry.name === preset.name; - }); - } + }; +}]); +/* + * (C) Copyright 2017 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. + */ - $scope.newznabPresets = [ - { - name: "abNZB", - host: "https://abnzb.com/" - }, - { - name: "altHUB", - host: "https://api.althub.co.za" +angular + .module('nzbhydraApp') + .directive('indexerStateSwitch', indexerStateSwitch); + +function indexerStateSwitch() { + controller.$inject = ["$scope"]; + return { + templateUrl: 'static/html/directives/indexer-state-switch.html', + scope: { + indexer: "=", + handleWidth: "@" }, - { - name: "Animetosho (Newznab)", - host: "https://feed.animetosho.org", - categories: ["Anime"], - supportedSearchIds: [], - supportedSearchTypes: ["SEARCH"], - allCapsChecked: true, - configComplete: true, - categoryMapping: { - anime: 5070, - audiobook: null, - comic: null, - ebook: null, - magazine: null, - categories: [ - { - id: 5070, - name: "Anime", - subCategories: [] - } - ] + replace: true, + controller: controller + }; + + function controller($scope) { + $scope.value = $scope.indexer.state === "ENABLED"; + $scope.handleWidth = $scope.handleWidth || "130px"; + var initialized = false; + + function calculateTextAndColor() { + if ($scope.indexer.state === "DISABLED_USER") { + $scope.offText = "Disabled by user"; + $scope.offColor = "default"; + } else if ($scope.indexer.state === "DISABLED_SYSTEM_TEMPORARY") { + $scope.offText = "Temporary disabled"; + $scope.offColor = "warning"; + } else if ($scope.indexer.state === "DISABLED_SYSTEM") { + $scope.offText = "Disabled by system"; + $scope.offColor = "danger"; } - }, - { - name: "Digital Carnage", - host: "https://digitalcarnage.info" - }, - { - name: "DogNZB", - host: "https://api.dognzb.cr" - }, - { - name: "Drunken Slug", - host: "https://api.drunkenslug.com" - }, - { - name: "FastNZB", - host: "https://fastnzb.com" - }, - { - name: "LuluNZB", - host: "https://lulunzb.com" - }, - { - name: "miatrix", - host: "https://www.miatrix.com" - }, - { - name: "NZB Finder", - host: "https://nzbfinder.ws" - }, - { - name: "NZBCat", - host: "https://nzb.cat" - }, - { - name: "nzb.su", - host: "https://api.nzb.su" - }, - { - name: "NZBGeek", - host: "https://api.nzbgeek.info" - }, - { - name: "NzbNdx", - host: "https://www.nzbndx.com" - }, - { - name: "NzBNooB", - host: "https://www.nzbnoob.com" - }, - { - name: "NzbNation", - host: "http://www.nzbnation.com/" - }, - { - name: "nzbplanet", - host: "https://nzbplanet.net" - }, - { - name: "omgwtfnzbs", - host: "https://api.omgwtfnzbs.org" - }, - { - name: "SceneNZBs", - host: "https://scenenzbs.com", - info: "If you want german or spanish (or other language specific) results make sure to add the newznab IDs in the categories config.
For example for german UHD movies add 2145.
You can find out the IDs by browsing https://scenenzbs.com/rss." - }, - { - name: "spotweb.com", - host: "https://spotweb.me" - }, - { - name: "Tabula-Rasa", - host: "https://www.tabula-rasa.pw/api/v1/" - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - hitLimit: null, - hitLimitResetTime: null, - host: "https://binsearch.info", - loadLimitOnRandom: null, - name: "Binsearch", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "BINSEARCH", - username: null - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - generalMinSize: 1, - hitLimit: null, - hitLimitResetTime: null, - host: "https://nzbindex.com", - loadLimitOnRandom: null, - name: "NZBIndex", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "NZBINDEX", - username: null - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - generalMinSize: 1, - hitLimit: null, - hitLimitResetTime: null, - host: "https://api.nzbindex.com", - loadLimitOnRandom: null, - name: "NZBIndex API", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "NZBINDEX_API", - username: null - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - generalMinSize: 1, - hitLimit: null, - hitLimitResetTime: null, - host: "https://beta.nzbindex.com/search", - loadLimitOnRandom: null, - name: "NZBIndex Beta", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "NZBINDEX_BETA", - username: null - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - hitLimit: null, - hitLimitResetTime: null, - host: "https://www.nzbking.com/search", - loadLimitOnRandom: null, - name: "NZBKing.com", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "NZBKING", - username: null - }, - { - allCapsChecked: true, - enabledForSearchSource: "INTERNAL", - categories: [], - configComplete: true, - downloadLimit: null, - generalMinSize: 1, - hitLimit: null, - hitLimitResetTime: null, - host: null, - loadLimitOnRandom: null, - name: "WtfNzb", - password: null, - preselect: true, - score: 0, - showOnSearch: true, - state: "ENABLED", - supportedSearchIds: [], - supportedSearchTypes: [], - timeout: null, - searchModuleType: "WTFNZB", - username: null, - userAgent: null } - ]; - $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) { - return entry.name.toLowerCase() - }); + calculateTextAndColor(); - $scope.torznabPresets = [ - { - allCapsChecked: false, - configComplete: false, - name: "Jackett/Cardigann", - host: "http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/", - supportedSearchIds: undefined, - supportedSearchTypes: undefined, - searchModuleType: "TORZNAB", - state: "ENABLED", - enabledForSearchSource: "BOTH" - }, - { - categories: ["Anime"], - allCapsChecked: true, - configComplete: true, - name: "Animetosho (Torznab)", - host: "https://feed.animetosho.org", - supportedSearchIds: [], - supportedSearchTypes: ["SEARCH"], - searchModuleType: "TORZNAB", - state: "ENABLED", - enabledForSearchSource: "BOTH" + $scope.onChange = function () { + if (initialized) { + //Skip on first call when initial value is set + $scope.indexer.state = $scope.value ? "ENABLED" : "DISABLED_USER"; + calculateTextAndColor(); + } + initialized = true; } - ]; + } +} +/* + * (C) Copyright 2017 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. + */ - $scope.emptyTorznabPreset = { - allCapsChecked: false, - configComplete: false, - supportedSearchIds: undefined, - supportedSearchTypes: undefined, - searchModuleType: "TORZNAB", - state: "ENABLED", - enabledForSearchSource: "BOTH" - }; - $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) { - return entry.name.toLowerCase() - }); -}]); +angular + .module('nzbhydraApp') + .directive('indexerSelectionButton', indexerSelectionButton); +function indexerSelectionButton() { + controller.$inject = ["$scope"]; + return { + templateUrl: 'static/html/directives/indexer-selection-button.html', + scope: { + selectedIndexers: "=", + availableIndexers: "=", + btn: "@" + }, + controller: controller + }; -angular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$http", "model", "form", "fields", "isInitial", "parentModel", "growl", "IndexerCheckBeforeCloseService", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) { + function controller($scope) { - $scope.model = model; - $scope.fields = fields; - $scope.isInitial = isInitial; - $scope.spinnerActive = false; - $scope.needsConnectionTest = false; + $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers, + function (indexer) { + return indexer.searchModuleType === "TORZNAB"; + } + ); - $scope.obSubmit = function () { - if (model.searchModuleType === 'JACKETT_CONFIG') { - $uibModalInstance.close(model); - } else if (form.$valid) { - var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) { - if (angular.isDefined(data)) { - $scope.model = data; + $scope.invertSelection = function () { + _.forEach($scope.availableIndexers, function (x) { + var index = _.indexOf($scope.selectedIndexers, x.name); + if (index === -1) { + $scope.selectedIndexers.push(x.name); + } else { + $scope.selectedIndexers.splice(index, 1); } - $uibModalInstance.close(data); - }); - } else { - growl.error("Config invalid. Please check your settings."); - angular.forEach(form.$error, function (error) { - angular.forEach(error, function (field) { - field.$setTouched(); - }); }); - } - }; + }; - $scope.cancel = function () { - $uibModalInstance.dismiss(); - }; + $scope.selectAll = function () { + $scope.deselectAll(); + $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, "name")); + }; - $scope.deleteEntry = function () { - parentModel.splice(parentModel.indexOf(model), 1); - $uibModalInstance.close($scope); - }; + $scope.deselectAll = function () { + $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length); + }; - $scope.reset = function () { - //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf)) - $scope.options.resetModel(); - $scope.options.resetModel(); - }; + function selectByPredicate(predicate) { + $scope.deselectAll(); + $scope.selectedIndexers.push.apply($scope.selectedIndexers, + _.pluck( + _.filter($scope.availableIndexers, + predicate + ), "name") + ); + } - $scope.$on("modal.closing", function (targetScope, reason) { - if (reason === "backdrop click") { - $scope.reset($scope); + $scope.reset = function () { + selectByPredicate(function (indexer) { + return indexer.preselect; + }); + }; + + $scope.selectAllUsenet = function () { + selectByPredicate(function (indexer) { + return indexer.searchModuleType !== "TORZNAB"; + }); + }; + + $scope.selectAllTorrent = function () { + selectByPredicate(function (indexer) { + return indexer.searchModuleType === "TORZNAB"; + }); } - }); -}]); + } +} angular .module('nzbhydraApp') - .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl); - -function CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) { + .directive('indexerInput', indexerInput); - var updateMessagesInterval = undefined; +function indexerInput() { + controller.$inject = ["$scope"]; + return { + templateUrl: 'static/html/directives/indexer-input.html', + scope: { + indexer: "=", + model: "=", + onClick: "=" + }, + replace: true, + controller: controller + }; - $scope.messages = undefined; - $http.post("internalapi/indexer/checkCaps", capsCheckRequest).then(function (response) { - $scope.$close([response.data, capsCheckRequest.indexerConfig]); - if (response.data.length === 0) { - growl.info("No indexers were checked"); - } - }, function () { - $scope.$dismiss("Unknown error") - }); + function controller($scope) { + $scope.isFocused = false; - $timeout( - updateMessagesInterval = $interval(function () { - $http.get("internalapi/indexer/checkCapsMessages").then(function (response) { - var map = response.data; - var messages = []; - for (var name in map) { - if (map.hasOwnProperty(name)) { - for (var i = 0; i < map[name].length; i++) { - var message = ""; - if (capsCheckRequest.checkType !== "SINGLE") { - message += name + ": "; - } - message += map[name][i]; - messages.push(message); - } - } - } - $scope.messages = messages; - }); + $scope.onFocus = function () { + $scope.isFocused = true; + }; - }, 500), - 500); + $scope.onBlur = function () { + $scope.isFocused = false; + }; + var expiryWarning; + if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== "Lifetime") { + var expiryDate = moment($scope.indexer.vipExpirationDate, "YYYY-MM-DD"); + if (expiryDate < moment()) { + console.log("Expiry date reached for indexer " + $scope.indexer.name); + expiryWarning = "VIP access expired on " + $scope.indexer.vipExpirationDate; + } else if (expiryDate.subtract(7, 'days') < moment()) { + console.log("Expiry date near for indexer " + $scope.indexer.name); + expiryWarning = "VIP access will expire on " + $scope.indexer.vipExpirationDate; + } + } - $scope.$on('$destroy', function () { - if (angular.isDefined(updateMessagesInterval)) { - $interval.cancel(updateMessagesInterval); + $scope.expiryWarning = expiryWarning; + if ($scope.indexer.color !== null) { + $scope.style = "background-color: " + $scope.indexer.color.replace("rgb", "rgba").replace(")", ",0.5)") } - }); + } + } + angular .module('nzbhydraApp') - .factory('IndexerConfigBoxService', IndexerConfigBoxService); - -function IndexerConfigBoxService($http, $q, $uibModal) { + .directive('hydraupdates', hydraupdates); +function hydraupdates() { + controller.$inject = ["$scope", "UpdateService"]; return { - checkConnection: checkConnection, - checkCaps: checkCaps + templateUrl: 'static/html/directives/updates.html', + controller: controller }; - function checkConnection(url, settings) { - var deferred = $q.defer(); + function controller($scope, UpdateService) { - $http.post(url, settings).then(function (result) { - //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click - if (result.data.successful) { - deferred.resolve({checked: true, message: null, model: result.data}); - } else { - deferred.reject({checked: true, message: result.data.message}); + $scope.loadingPromise = UpdateService.getInfos().then(function (response) { + $scope.currentVersion = response.data.currentVersion; + $scope.latestVersion = response.data.latestVersion; + $scope.latestVersionIsBeta = response.data.latestVersionIsBeta; + $scope.betaVersion = response.data.betaVersion; + $scope.updateAvailable = response.data.updateAvailable; + $scope.betaUpdateAvailable = response.data.betaUpdateAvailable; + $scope.latestVersionIgnored = response.data.latestVersionIgnored; + $scope.changelog = response.data.changelog; + $scope.updatedExternally = response.data.updatedExternally; + $scope.wrapperOutdated = response.data.wrapperOutdated; + $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally; + if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) { + $scope.updateAvailable = false; } - }, function (result) { - deferred.reject({checked: false, message: result.data.message}); }); - return deferred.promise; - } - - function checkCaps(capsCheckRequest) { - var deferred = $q.defer(); - - var result = $uibModal.open({ - templateUrl: 'static/html/checker-state.html', - controller: CheckCapsModalInstanceCtrl, - size: "md", - backdrop: "static", - backdropClass: "waiting-cursor", - resolve: { - capsCheckRequest: function () { - return capsCheckRequest; - } - } + UpdateService.getVersionHistory().then(function (response) { + $scope.versionHistory = response.data; }); - result.result.then(function (data) { - deferred.resolve(data[0], data[1]); - }, function (message) { - deferred.reject(message); - }); - return deferred.promise; - } + $scope.update = function (version) { + UpdateService.update(version); + }; -} + $scope.showChangelog = function (version) { + UpdateService.showChanges(version); + }; -angular - .module('nzbhydraApp') - .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService); + $scope.forceUpdate = function () { + UpdateService.update($scope.latestVersion) + }; + } +} -function IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) { +angular + .module('nzbhydraApp') + .directive('hydraNews', hydraNews); + +function hydraNews() { + controller.$inject = ["$scope", "$http"]; return { - checkBeforeClose: checkBeforeClose + templateUrl: "static/html/directives/news.html", + controller: controller }; - function checkBeforeClose(scope, model) { - var deferred = $q.defer(); - if (model.searchModuleType === 'JACKETT_CONFIG') { - deferred.resolve(model); - } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) { - checkCapsWhenClosing(scope, model).then(function () { - deferred.resolve(model); - }, function () { - deferred.reject(); - }); - } else { - scope.spinnerActive = true; - blockUI.start("Testing connection..."); - var url = "internalapi/indexer/checkConnection"; - IndexerConfigBoxService.checkConnection(url, model).then(function () { - growl.info("Connection to the indexer tested successfully"); - checkCapsWhenClosing(scope, model).then(function (data) { - scope.spinnerActive = false; - blockUI.reset(); - deferred.resolve(data); - }, function () { - scope.spinnerActive = false; - blockUI.reset(); - deferred.reject(); - }); - }, - function (data) { - scope.spinnerActive = false; - blockUI.reset(); - handleConnectionCheckFail(ModalService, data, model, "indexer", deferred); - }); - } - return deferred.promise; - } + function controller($scope, $http) { - //Called when the indexer dialog is closed - function checkCapsWhenClosing(scope, model) { - var deferred = $q.defer(); - if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) { + return $http.get("internalapi/news").then(function (response) { + $scope.news = response.data; + }); - blockUI.start("New indexer found. Testing its capabilities. This may take a bit..."); - IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: "SINGLE"}).then( - function (data) { - data = data[0]; //We get a list of results (with one result because the check type is single) - blockUI.reset(); - scope.spinnerActive = false; - if (data.allCapsChecked && data.configComplete) { - growl.info("Successfully tested capabilites of indexer"); - } else if (!data.allCapsChecked && data.configComplete) { - ModalService.open("Incomplete caps check", "The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
Until then some search types or IDs may not be usable.", {}, "md", "left"); - } else if (!data.configComplete) { - ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); - } - deferred.resolve(data.indexerConfig); - }, - function () { - blockUI.reset(); - scope.spinnerActive = false; - model.supportedSearchIds = undefined; - model.supportedSearchTypes = undefined; - ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.", {}, "md", "left"); - deferred.resolve(); - }).finally( - function () { - scope.spinnerActive = false; - }) - } else { - deferred.resolve(); - } - return deferred.promise; } } -/* - * (C) Copyright 2017 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. - */ -DownloaderConfigBoxService.$inject = ["$http", "$q", "$uibModal"]; -DownloaderCheckBeforeCloseService.$inject = ["$q", "DownloaderConfigBoxService", "growl", "ModalService", "blockUI"]; -angular - .module('nzbhydraApp') - .config(["formlyConfigProvider", function config(formlyConfigProvider) { - formlyConfigProvider.setType({ - name: 'downloaderConfig', - templateUrl: 'static/html/config/downloader-config.html', - controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) { - $scope.formOptions = {formState: $scope.formState}; - $scope._showBox = _showBox; - $scope.showBox = showBox; - $scope.isInitial = false; - $scope.presets = [ - { - name: "NZBGet", - downloaderType: "NZBGET", - username: "nzbgetx", - nzbAddingType: "UPLOAD", - nzbAccessType: "REDIRECT", - iconCssClass: "", - downloadType: "NZB", - url: "http://nzbget:tegbzn6789@localhost:6789" - }, - { - url: "http://localhost:8080", - downloaderType: "SABNZBD", - name: "SABnzbd", - nzbAddingType: "UPLOAD", - nzbAccessType: "REDIRECT", - iconCssClass: "", - downloadType: "NZB" - } - ]; - function _showBox(model, parentModel, isInitial, callback) { - var modalInstance = $uibModal.open({ - templateUrl: 'static/html/config/downloader-config-box.html', - controller: 'DownloaderConfigBoxInstanceController', - size: 'lg', - resolve: { - model: function () { - //Isn't properly stored in parentmodel for some reason, this works just as well - model.showAdvanced = localStorageService.get("showAdvanced"); - console.log(model.showAdvanced); - return model; - }, - fields: function () { - return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService); - }, - isInitial: function () { - return isInitial - }, - parentModel: function () { - return parentModel; - }, - data: function () { - return $scope.options.data; - } - } - }); +LogModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "entry"]; +escapeHtml.$inject = ["$sanitize"];angular + .module('nzbhydraApp') + .directive('hydralog', hydralog); +function hydralog() { + controller.$inject = ["$scope", "$http", "$interval", "$uibModal", "$sce", "localStorageService", "growl"]; + return { + templateUrl: "static/html/directives/log.html", + controller: controller + }; - modalInstance.result.then(function (returnedModel) { - $scope.form.$setDirty(true); - if (angular.isDefined(callback)) { - callback(true, returnedModel); - } - }, function () { - if (angular.isDefined(callback)) { - callback(false); - } - }); - } + function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) { + $scope.tailInterval = null; + $scope.doUpdateLog = localStorageService.get("doUpdateLog") !== null ? localStorageService.get("doUpdateLog") : false; + $scope.doTailLog = localStorageService.get("doTailLog") !== null ? localStorageService.get("doTailLog") : false; - function showBox(model, parentModel) { - $scope._showBox(model, parentModel, false) - } + $scope.active = 0; + $scope.currentJsonIndex = 0; + $scope.hasMoreJsonLines = true; - $scope.addEntry = function (entriesCollection, preset) { - var model = angular.copy({ - enabled: true - }); - if (angular.isDefined(preset)) { - _.extend(model, preset); + function getLog(index) { + if ($scope.active === 0) { + return $http.get("internalapi/debuginfos/jsonlogs", { + params: { + offset: index, + limit: 500 } + }).then(function (response) { + var data = response.data; + $scope.jsonLogLines = angular.fromJson(data.lines); + $scope.hasMoreJsonLines = data.hasMore; + }); + } else if ($scope.active === 1) { + return $http.get("internalapi/debuginfos/currentlogfile").then(function (response) { + var data = response.data; + $scope.log = $sce.trustAsHtml(data.replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'")); + }, function (data) { + growl.error(data) + }); + } else if ($scope.active === 2) { + return $http.get("internalapi/debuginfos/logfilenames").then(function (response) { + $scope.logfilenames = response.data; + }); + } + } - $scope.isInitial = true; + $scope.logPromise = getLog(); - $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) { - if (isSubmitted) { - //Here is where the entry is actually added to the model - entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model); - } - }); - }; + $scope.select = function (index) { + $scope.active = index; + $scope.update(); + }; - function getDownloaderBoxFields(model, parentModel, isInitial) { - var fieldset = []; + $scope.scrollToBottom = function () { + document.getElementById("logfile").scrollTop = 10000000; + document.getElementById("logfile").scrollTop = 100001000; + }; - fieldset = _.union(fieldset, [ - { - key: 'enabled', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Enabled' - } - }, - { - key: 'name', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Name', - required: true - }, - validators: { - uniqueName: { - expression: function (viewValue) { - if (isInitial || viewValue !== model.name) { - return _.pluck(parentModel, "name").indexOf(viewValue) === -1; - } - return true; - }, - message: '"Downloader \\"" + $viewValue + "\\" already exists"' - } - } + $scope.update = function () { + getLog($scope.currentJsonIndex); + if ($scope.active === 1) { + $scope.scrollToBottom(); + } + }; - }, - { - key: 'url', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'URL', - help: 'URL with scheme and full path', - required: true - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - } - ]); + $scope.getOlderFormatted = function () { + getLog($scope.currentJsonIndex + 500).then(function () { + $scope.currentJsonIndex += 500; + }); + }; - if (model.downloaderType === "SABNZBD") { - fieldset.push({ - key: 'apiKey', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'API Key' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - }) - } else if (model.downloaderType === "NZBGET") { - fieldset.push({ - key: 'username', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Username' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - }); - fieldset.push({ - key: 'password', - type: 'passwordSwitch', - templateOptions: { - type: 'text', - label: 'Password' - }, - watcher: { - listener: function (field, newValue, oldValue, scope) { - if (newValue !== oldValue) { - scope.$parent.needsConnectionTest = true; - } - } - } - }) + $scope.getNewerFormatted = function () { + var index = Math.max($scope.currentJsonIndex - 500, 0); + getLog(index); + $scope.currentJsonIndex = index; + }; + + function startUpdateLogInterval() { + $scope.tailInterval = $interval(function () { + if ($scope.active === 1) { + $scope.update(); + if ($scope.doTailLog && $scope.active === 1) { + $scope.scrollToBottom(); } + } + }, 5000); + } - fieldset = _.union(fieldset, [ - { - key: 'defaultCategory', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Default category', - help: 'When adding NZBs this category will be used instead of asking for the category. Write "Use original category", "Use no category" or "Use mapped category" to not be asked.', - placeholder: 'Ask when downloading' - } - }, - { - key: 'nzbAddingType', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'NZB adding type', - options: [ - {name: 'Send link', value: 'SEND_LINK'}, - {name: 'Upload NZB', value: 'UPLOAD'} - ], - help: "How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.", - tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' + - '
Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.', - advanced: true - } - }, - { - key: 'addPaused', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Add paused', - help: 'Add NZBs paused', - advanced: true - } - }, - { - key: 'iconCssClass', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Icon CSS class', - help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. "film")', - placeholder: 'Default', - tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.', - advanced: true - } - } - ]); + $scope.toggleUpdate = function (doUpdateLog) { + $scope.doUpdateLog = doUpdateLog; + if ($scope.doUpdateLog) { + startUpdateLogInterval(); + } else if ($scope.tailInterval !== null) { + console.log("Cancelling"); + $interval.cancel($scope.tailInterval); + localStorageService.set("doTailLog", false); + $scope.doTailLog = false; + } + localStorageService.set("doUpdateLog", $scope.doUpdateLog); + }; - return fieldset; + $scope.toggleTailLog = function () { + localStorageService.set("doTailLog", $scope.doTailLog); + }; + + $scope.openModal = function openModal(entry) { + var modalInstance = $uibModal.open({ + templateUrl: 'log-entry.html', + controller: LogModalInstanceCtrl, + size: "xl", + resolve: { + entry: function () { + return entry; + } } + }); + + modalInstance.result.then(); + }; + + $scope.$on('$destroy', function () { + if ($scope.tailInterval !== null) { + $interval.cancel($scope.tailInterval); } }); - }]); + if ($scope.doUpdateLog) { + startUpdateLogInterval(); + } + + + } +} angular .module('nzbhydraApp') - .factory('DownloaderConfigBoxService', DownloaderConfigBoxService); + .controller('LogModalInstanceCtrl', LogModalInstanceCtrl); -function DownloaderConfigBoxService($http, $q, $uibModal) { +function LogModalInstanceCtrl($scope, $uibModalInstance, entry) { - return { - checkConnection: checkConnection, - checkCaps: checkCaps - }; + $scope.entry = entry; - function checkConnection(url, settings) { - var deferred = $q.defer(); + $scope.ok = function () { + $uibModalInstance.dismiss(); + }; +} - $http.post(url, settings).then(function (result) { - //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click - if (result.data.successful) { - deferred.resolve({checked: true, message: null, model: result.data}); - } else { - deferred.reject({checked: true, message: result.data.message}); - } - }, function (result) { - deferred.reject({checked: false, message: result.data.message}); - }); +angular + .module('nzbhydraApp') + .filter('formatTimestamp', formatTimestamp); - return deferred.promise; +function formatTimestamp() { + return function (date) { + //1579392000 + //1579374757 + if (date === null || date === undefined) { + return null; + } + if (date < 1979374757) { + date *= 1000; + } + return moment(date).local().format("YYYY-MM-DD HH:mm"); } +} - function checkCaps(capsCheckRequest) { - var deferred = $q.defer(); +angular + .module('nzbhydraApp') + .filter('escapeHtml', escapeHtml); - var result = $uibModal.open({ - templateUrl: 'static/html/checker-state.html', - controller: CheckCapsModalInstanceCtrl, - size: "md", - backdrop: "static", - backdropClass: "waiting-cursor", - resolve: { - capsCheckRequest: function () { - return capsCheckRequest; - } - } - }); - - result.result.then(function (data) { - deferred.resolve(data[0], data[1]); - }, function (message) { - deferred.reject(message); - }); - - return deferred.promise; +function escapeHtml($sanitize) { + return function (text) { + return $sanitize(text); } } -angular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$http", "model", "fields", "isInitial", "parentModel", "data", "growl", "DownloaderCheckBeforeCloseService", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) { - - $scope.model = model; - $scope.fields = fields; - $scope.isInitial = isInitial; - $scope.spinnerActive = false; - $scope.needsConnectionTest = false; - - $scope.obSubmit = function () { - if ($scope.form.$valid) { - var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) { - if (angular.isDefined(data)) { - $scope.model = data; - } - $uibModalInstance.close(data); - }); - } else { - growl.error("Config invalid. Please check your settings."); - angular.forEach($scope.form.$error, function (error) { - angular.forEach(error, function (field) { - field.$setTouched(); - }); - }); - } - }; - - $scope.cancel = function () { - $uibModalInstance.dismiss(); - }; - - $scope.deleteEntry = function () { - parentModel.splice(parentModel.indexOf(model), 1); - $uibModalInstance.close($scope); - }; - - $scope.reset = function () { - if (angular.isDefined(data.resetFunction)) { - //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf)) - $scope.options.resetModel(); - $scope.options.resetModel(); - } - }; - - $scope.$on("modal.closing", function (targetScope, reason) { - if (reason === "backdrop click") { - $scope.reset($scope); - } - }); -}]); - - angular .module('nzbhydraApp') - .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService); - -function DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) { + .filter('formatClassname', formatClassname); - return { - checkBeforeClose: checkBeforeClose - }; +function formatClassname() { + return function (fqn) { + return fqn.substr(fqn.lastIndexOf(".") + 1); - function checkBeforeClose(scope, model) { - var deferred = $q.defer(); - if (!scope.isInitial && !scope.needsConnectionTest) { - deferred.resolve(); - } else { - scope.spinnerActive = true; - blockUI.start("Testing connection..."); - var url = "internalapi/downloader/checkConnection"; - DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () { - blockUI.reset(); - scope.spinnerActive = false; - growl.info("Connection to the downloader tested successfully"); - deferred.resolve(); - }, - function (data) { - blockUI.reset(); - scope.spinnerActive = false; - handleConnectionCheckFail(ModalService, data, model, "downloader", deferred); - }).finally(function () { - scope.spinnerActive = false; - blockUI.reset(); - }); - } - return deferred.promise; } } /* @@ -2666,3556 +2014,1553 @@ function DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl * limitations under the License. */ -hashCode = function (s) { - return s.split("").reduce(function (a, b) { - a = ((a << 5) - a) + b.charCodeAt(0); - return a & a - }, 0); -}; - -angular - .module('nzbhydraApp').run(["formlyConfig", "formlyValidationMessages", function (formlyConfig, formlyValidationMessages) { - formlyValidationMessages.addStringMessage('required', 'This field is required'); - formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid'); - formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted'; -}]); - +NewsModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "news"]; +WelcomeModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$state", "MigrationService"]; angular .module('nzbhydraApp') - .config(["formlyConfigProvider", function config(formlyConfigProvider) { - formlyConfigProvider.extras.removeChromeAutoComplete = true; - formlyConfigProvider.extras.explicitAsync = true; - formlyConfigProvider.disableWarnings = window.onProd; - + .directive('hydraChecksFooter', hydraChecksFooter); - formlyConfigProvider.setWrapper({ - name: 'settingWrapper', - templateUrl: 'setting-wrapper.html' - }); +function hydraChecksFooter() { + controller.$inject = ["$scope", "UpdateService", "RequestsErrorHandler", "HydraAuthService", "$http", "$uibModal", "ConfigService", "GenericStorageService", "ModalService", "growl", "NotificationService", "bootstrapped"]; + return { + templateUrl: 'static/html/directives/checks-footer.html', + controller: controller + }; + function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) { + $scope.updateAvailable = false; + $scope.checked = false; + var welcomeIsBeingShown = false; - formlyConfigProvider.setWrapper({ - name: 'fieldset', - templateUrl: 'fieldset-wrapper.html', - controller: ['$scope', function ($scope) { - $scope.tooltipIsOpen = false; - }] - }); + $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin; - formlyConfigProvider.setType({ - name: 'help', - template: [ - '
', - '
', - '
', - '
{{ line | derefererExtracting | unsafe }}
', - '
', - '
', - '
' - ].join(' ') + $scope.$on("user:loggedIn", function () { + if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) { + retrieveUpdateInfos(); + } }); + function checkForOutOfMemoryException() { + GenericStorageService.get("outOfMemoryDetected", false).then(function (response) { + if (response.data !== "" && response.data) { + //headline, message, params, size, textAlign + ModalService.open("Out of memory error detected", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', { + yes: { + text: "OK" + } + }, undefined, "left"); + GenericStorageService.put("outOfMemoryDetected", false, false); + } + }); + } - formlyConfigProvider.setWrapper({ - name: 'logicalGroup', - template: [ - '' - ].join(' ') - }); + function checkForOpenToInternet() { + GenericStorageService.get("showOpenToInternetWithoutAuth", false).then(function (response) { + if (response.data !== "" && response.data) { + //headline, message, params, size, textAlign + ModalService.open("Security issue - open to internet", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', { + yes: { + text: "OK" + } + }, undefined, "left"); + GenericStorageService.put("showOpenToInternetWithoutAuth", false, false); + } + }); + } - formlyConfigProvider.setType({ - name: 'horizontalInput', - extends: 'input', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); + console.log("Checking for below Java 17."); - formlyConfigProvider.setType({ - name: 'horizontalTextArea', - extends: 'textarea', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); + function checkForJavaBelow17() { + GenericStorageService.get("belowJava17", false).then(function (response) { + if (response.data !== "" && response.data) { + console.log("Java below 17"); + //headline, message, params, size, textAlign + ModalService.open("Java version below 17", 'You\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', { + yes: { + text: "OK" + } + }, undefined, "left"); + GenericStorageService.put("belowJava17", false, false); + } + }); + } - formlyConfigProvider.setType({ - name: 'timeOfDay', - extends: 'horizontalInput', - controller: ['$scope', function ($scope) { - $scope.model[$scope.options.key] = moment.utc($scope.model[$scope.options.key]).toDate(); - }] - }); + console.log("Checking for failed backup."); - formlyConfigProvider.setType({ - name: 'passwordSwitch', - extends: 'horizontalInput', - template: [ - '
', - '', - '', - '', - '
' - ].join(' '), - controller: function ($scope) { - $scope.hidePassword = true; - } - }); + function checkForFailedBackup() { + GenericStorageService.get("FAILED_BACKUP", false).then(function (response) { + if (response.data !== "" && response.data && !response.data) { + console.log("Failed backup detected"); + //headline, message, params, size, textAlign + ModalService.open("Failed backup", 'The creation of a backup file has failed. Error message: \"' + response.data.message + '."
For details please check the log around ' + response.data.time + '.', { + yes: { + text: "OK" + } + }, undefined, "left"); + GenericStorageService.put("FAILED_BACKUP", false, null); + } + }); + } - formlyConfigProvider.setType({ - name: 'horizontalChips', - extends: 'horizontalInput', - template: '' + - ' ' + - '
' + - ' {{chip}}' + - ' ' + - '
' + - '
' + - ' ' + - '
' - }); - - formlyConfigProvider.setType({ - name: 'percentInput', - template: [ - '' - ].join(' ') - }); + function checkForOutdatedWrapper() { + $http.get("internalapi/updates/isDisplayWrapperOutdated").then(function (response) { + var data = response.data; + if (data !== undefined && data !== null && data) { + ModalService.open("Outdated wrappers detected", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.

\n' + + ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder.
\n' + + ' For Windows these files are:\n' + + ' \n' + + ' For linux these files are:\n' + + ' \n' + + ' Make sure to overwrite all of these files that already exist - you don\'t need to update any files that aren\'t already present.\n' + + '

\n' + + ' Afterwards start NZBHydra again.', { + yes: { + text: "OK", + onYes: function () { + $http.put("internalapi/updates/setOutdatedWrapperDetectedWarningShown") + } + } + }, undefined, "left"); - formlyConfigProvider.setType({ - name: 'apiKeyInput', - template: [ - '
', - '', - '', - '', - '
' - ].join(' '), - controller: function ($scope) { - $scope.generate = function () { - var result = ""; - var length = 24; - var chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - for (var i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]; - $scope.model[$scope.options.key] = result; - $scope.form.$setDirty(true); } - } - }); + }); + } - formlyConfigProvider.setType({ - name: 'fileInput', - extends: 'horizontalInput', - template: [ - '
', - '', - '', - '', - '
' - ].join(' '), - controller: function ($scope, FileSelectionService) { - $scope.open = function () { - FileSelectionService.open($scope.model[$scope.options.key], $scope.to.type).then(function (selection) { - $scope.model[$scope.options.key] = selection; - }); - } - } - }); + if ($scope.mayUpdate) { + retrieveUpdateInfos(); + checkForOutOfMemoryException(); + checkForOutdatedWrapper(); + checkForOpenToInternet(); + checkForJavaBelow17(); + checkForFailedBackup(); + } - formlyConfigProvider.setType({ - name: 'colorInput', - extends: 'horizontalInput', - templateUrl: 'static/html/config/color-control.html', - controller: function ($scope) { - //Model format: rgb(116,18,18) - //Input format: rgba(100,42,41,0.5) - if (!_.isNullOrEmpty($scope.model.color)) { - $scope.color = $scope.model.color; - } - $scope.convertColorToCss = function () { - if (_.isNullOrEmpty($scope.model.color)) { - return ""; - } - return $scope.model.color.replace("rgb", "rgba").replace(")", ",0.5)"); - } - $scope.convertColorFromInput = function () { - if (_.isNullOrEmpty($scope.color)) { - return; + function retrieveUpdateInfos() { + $scope.checked = true; + UpdateService.getInfos().then(function (response) { + if (response) { + $scope.currentVersion = response.data.currentVersion; + $scope.latestVersion = response.data.latestVersion; + $scope.latestVersionIsBeta = response.data.latestVersionIsBeta; + $scope.updateAvailable = response.data.updateAvailable; + $scope.changelog = response.data.changelog; + $scope.updatedExternally = response.data.updatedExternally; + $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally; + $scope.showWhatsNewBanner = response.data.showWhatsNewBanner; + if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) { + $scope.updateAvailable = false; } - $scope.model.color = $scope.color.replace("rgba", "rgb").replace(",0.5)", ")"); - } - $scope.clear = function () { - $scope.model.color = null; - $scope.color = null; + $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice; + + + $scope.$emit("showUpdateFooter", $scope.updateAvailable); + $scope.$emit("showAutomaticUpdateFooter", $scope.automaticUpdateToNotice); + } else { + $scope.$emit("showUpdateFooter", false); } - $scope.$watch("model.color", function () { - if (!_.isNullOrEmpty($scope.model.color)) { - $scope.color = $scope.model.color; - } - }) - } - }); + }); + } - formlyConfigProvider.setType({ - name: 'testConnection', - templateUrl: 'button-test-connection.html' - }); + $scope.update = function () { + UpdateService.update($scope.latestVersion); + }; - formlyConfigProvider.setType({ - name: 'horizontalTestConnection', - extends: 'testConnection', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); + $scope.ignore = function () { + UpdateService.ignore($scope.latestVersion); + $scope.updateAvailable = false; + $scope.$emit("showUpdateFooter", $scope.updateAvailable); + }; - formlyConfigProvider.setType({ - name: 'customMappingTest', - extends: 'horizontalInput', - template: [ - '
', - '', - '
' - ].join(' '), - controller: function ($scope, $uibModal, $http) { - $scope.open = function () { - var model = $scope.model; - var modelCopy = structuredClone(model); - $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.showChangelog = function () { + UpdateService.showChanges($scope.latestVersion); + }; - } + $scope.showChangesFromAutomaticUpdate = function () { + UpdateService.showChangesFromAutomaticUpdate(); + $scope.automaticUpdateToNotice = null; + $scope.$emit("showAutomaticUpdateFooter", false); + }; - $scope.test = function () { - if (!$scope.exampleInput) { - $scope.exampleResult = "Empty example data"; - return; + $scope.dismissChangesFromAutomaticUpdate = function () { + $scope.automaticUpdateToNotice = null; + $scope.$emit("showAutomaticUpdateFooter", false); + console.log("Dismissing showAutomaticUpdateFooter"); + return $http.get("internalapi/updates/ackAutomaticUpdateVersionHistory").then(function (response) { + }); + }; - } - console.log("custom mapping test"); - $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).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 checkAndShowNews() { + RequestsErrorHandler.specificallyHandled(function () { + if (ConfigService.getSafe().showNews) { + $http.get("internalapi/news/forcurrentversion").then(function (response) { + var data = response.data; + if (data && data.length > 0) { + $uibModal.open({ + templateUrl: 'static/html/news-modal.html', + controller: NewsModalInstanceCtrl, + size: "lg", + resolve: { + news: function () { + return data; } - }, function (response) { - $scope.exampleResult = response.message; - }) - } - }], - size: "md" - }) + } + }); + $http.put("internalapi/news/saveshown"); + } + }); } - } - }); - - function updateIndexerModel(model, indexerConfig) { - model.supportedSearchIds = indexerConfig.supportedSearchIds; - model.supportedSearchTypes = indexerConfig.supportedSearchTypes; - model.categoryMapping = indexerConfig.categoryMapping; - model.configComplete = indexerConfig.configComplete; - model.allCapsChecked = indexerConfig.allCapsChecked; - model.hitLimit = indexerConfig.hitLimit; - model.downloadLimit = indexerConfig.downloadLimit; - model.state = indexerConfig.state; - model.backend = indexerConfig.backend; + }); } - formlyConfigProvider.setType({ - //BUtton - name: 'checkCaps', - templateUrl: 'button-check-caps.html', - controller: function ($scope, IndexerConfigBoxService, ModalService, growl) { - $scope.message = ""; - $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host); + function checkExpiredIndexers() { + _.each(ConfigService.getSafe().indexers, function (indexer) { + if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== "Lifetime") { + var expiryWarning; + var expiryDate = moment(indexer.vipExpirationDate, "YYYY-MM-DD"); + var messagePrefix = "VIP access for indexer " + indexer.name; + if (expiryDate < moment()) { + expiryWarning = messagePrefix + " expired on " + indexer.vipExpirationDate; + } else if (expiryDate.subtract(7, 'days') < moment()) { + expiryWarning = messagePrefix + " will expire on " + indexer.vipExpirationDate; + } + if (expiryWarning) { + console.log(expiryWarning); + growl.warning(expiryWarning); + } + } + }); + } - var testButton = "#button-check-caps-" + $scope.uniqueId; - var testMessage = "#message-check-caps-" + $scope.uniqueId; + function checkAndShowWelcome() { + RequestsErrorHandler.specificallyHandled(function () { + $http.get("internalapi/welcomeshown").then(function (response) { + if (!response.data) { + $http.put("internalapi/welcomeshown"); + var promise = $uibModal.open({ + templateUrl: 'static/html/welcome-modal.html', + controller: WelcomeModalInstanceCtrl, + size: "md" + }); + promise.opened.then(function () { + welcomeIsBeingShown = true; + }); + promise.closed.then(function () { + welcomeIsBeingShown = false; + }); + } else { + if (HydraAuthService.getUserInfos().maySeeAdmin) { + _.defer(checkAndShowNews); + _.defer(checkExpiredIndexers); + } + } + }, function () { + console.log("Error while checking for welcome") + }); + }); + } - function showSuccess() { - angular.element(testButton).removeClass("btn-default"); - angular.element(testButton).removeClass("btn-danger"); - angular.element(testButton).removeClass("btn-warning"); - angular.element(testButton).addClass("btn-success"); - } + checkAndShowWelcome(); - function showError() { - angular.element(testButton).removeClass("btn-default"); - angular.element(testButton).removeClass("btn-warning"); - angular.element(testButton).removeClass("btn-success"); - angular.element(testButton).addClass("btn-danger"); + function showUnreadNotifications(unreadNotifications, stompClient) { + if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) { + growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true}); + for (var i = 0; i < unreadNotifications.length; i++) { + if (unreadNotifications[i].id === undefined) { + console.log("Undefined ID found for notification " + unreadNotifications[i]); + continue; + } + stompClient.send("/app/markNotificationRead", {}, unreadNotifications[i].id); } - - function showWarning() { - angular.element(testButton).removeClass("btn-default"); - angular.element(testButton).removeClass("btn-danger"); - angular.element(testButton).removeClass("btn-success"); - angular.element(testButton).addClass("btn-warning"); + return; + } + for (var j = 0; j < unreadNotifications.length; j++) { + var notification = unreadNotifications[j]; + var body = notification.body.replace("\n", "
"); + switch (notification.messageType) { + case "INFO": + growl.info(body); + break; + case "SUCCESS": + growl.success(body); + break; + case "WARNING": + growl.warning(body); + break; + case "FAILURE": + growl.danger(body); + break; } - - - //When button is clicked - $scope.checkCaps = function () { - angular.element(testButton).addClass("glyphicon-refresh-animate"); - IndexerConfigBoxService.checkCaps({ - indexerConfig: $scope.model, - checkType: "SINGLE" - }).then(function (data) { - data = data[0]; //We get a list of results (with one result because the check type is single) - //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves - updateIndexerModel($scope.model, data.indexerConfig); - if (data.indexerConfig.supportedSearchIds.length > 0) { - var message = "Supports " + data.indexerConfig.supportedSearchIds; - angular.element(testMessage).text(message); - } - if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) { - showSuccess(); - growl.info("Successfully tested capabilites of indexer"); - $scope.form.capsChecked = true; - } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) { - showWarning(); - ModalService.open("Incomplete caps check", "The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
Until then some search types or IDs may not be usable.", {}, "md", "left"); - $scope.form.capsChecked = true; - } else if (!data.configComplete) { - showError(); - ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); - } - }, function (message) { - angular.element(testMessage).text(message); - showError(); - ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); - }).finally(function () { - angular.element(testButton).removeClass("glyphicon-refresh-animate"); - }); + if (notification.id === undefined) { + console.log("Undefined ID found for notification " + unreadNotifications[i]); + continue; } + stompClient.send("/app/markNotificationRead", {}, notification.id); } - }); + } - formlyConfigProvider.setType({ - name: 'horizontalCheckCaps', - extends: 'checkCaps', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); + if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) { + var socket = new SockJS(bootstrapped.baseUrl + 'websocket'); + var stompClient = Stomp.over(socket); + stompClient.debug = null; + stompClient.connect({}, function (frame) { + stompClient.subscribe('/topic/notifications', function (message) { + showUnreadNotifications(JSON.parse(message.body), stompClient); + }); + }); + } + } +} - formlyConfigProvider.setType({ - name: 'horizontalApiKeyInput', - extends: 'apiKeyInput', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); +angular + .module('nzbhydraApp') + .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl); - formlyConfigProvider.setType({ - name: 'horizontalPercentInput', - extends: 'percentInput', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); +function NewsModalInstanceCtrl($scope, $uibModalInstance, news) { + $scope.news = news; + $scope.close = function () { + $uibModalInstance.dismiss(); + }; +} +angular + .module('nzbhydraApp') + .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl); - formlyConfigProvider.setType({ - name: 'switch', - template: '
' - }); +function WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) { + $scope.close = function () { + $uibModalInstance.dismiss(); + }; - formlyConfigProvider.setType({ - name: 'indexerStateSwitch', - template: '' - }); + $scope.startMigration = function () { + $uibModalInstance.dismiss(); + MigrationService.migrate(); + }; + $scope.goToConfig = function () { + $uibModalInstance.dismiss(); + $state.go("root.config.main"); + } +} - formlyConfigProvider.setType({ - name: 'horizontalIndexerStateSwitch', - extends: 'indexerStateSwitch', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); +/* + * (C) Copyright 2017 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. + */ +angular + .module('nzbhydraApp') + .directive('footer', footer); - formlyConfigProvider.setType({ - name: 'duoSetting', - extends: 'input', - defaultOptions: { - className: 'col-md-9', - templateOptions: { - type: 'number', - noRow: true, - label: '' - } - } - }); +function footer() { + controller.$inject = ["$scope", "$http", "$uibModal", "ConfigService", "GenericStorageService", "bootstrapped"]; + return { + templateUrl: 'static/html/directives/footer.html', + controller: controller + }; - formlyConfigProvider.setType({ - name: 'horizontalSwitch', - extends: 'switch', - wrapper: ['settingWrapper', 'bootstrapHasError'] - }); - - formlyConfigProvider.setType({ - name: 'horizontalSelect', - extends: 'select', - wrapper: ['settingWrapper', 'bootstrapHasError'], - controller: function ($scope) { - if ($scope.options.templateOptions.optionsFunction !== undefined) { - $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model)); - } - if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) { - $scope.options.templateOptions.optionsFunctionAfter($scope.model); - } - } - }); + function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) { + $scope.updateFooterBottom = 0; + var safeConfig = bootstrapped.safeConfig; + $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) { + return x.enabled + }).length > 0; + $scope.showUpdateFooter = false; - formlyConfigProvider.setType({ - name: 'horizontalMultiselect', - defaultOptions: { - templateOptions: { - optionsAttr: 'bs-options', - ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search' - } - }, - template: '', - controller: function ($scope) { - var settings = $scope.to.settings || []; - settings.classes = settings.classes || []; - angular.extend(settings.classes, ["form-control"]); - $scope.settings = settings; - if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) { - $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model)); - } - $scope.events = { - onToggleItem: function (item, newValue) { - $scope.form.$setDirty(true); - } - } - }, - wrapper: ['settingWrapper', 'bootstrapHasError'] + $scope.$on("showDownloaderStatus", function (event, doShow) { + $scope.showDownloaderStatus = doShow; + updateFooterBottom(); + updatePaddingBottom(); }); - - formlyConfigProvider.setType({ - name: 'label', - template: '' + $scope.$on("showUpdateFooter", function (event, doShow) { + $scope.showUpdateFooter = doShow; + updateFooterBottom(); + updatePaddingBottom(); }); - - formlyConfigProvider.setType({ - name: 'duolabel', - extends: 'label', - defaultOptions: { - className: 'col-md-2', - templateOptions: { - label: '-' - } - } + $scope.$on("showAutomaticUpdateFooter", function (event, doShow) { + $scope.showAutomaticUpdateFooter = doShow; + updateFooterBottom(); + updatePaddingBottom(); }); - formlyConfigProvider.setType({ - name: 'repeatSection', - templateUrl: 'repeatSection.html', - controller: function ($scope) { - $scope.formOptions = {formState: $scope.formState}; - $scope.addNew = addNew; - $scope.remove = remove; - $scope.copyFields = copyFields; - - function copyFields(fields) { - fields = angular.copy(fields); - $scope.repeatfields = fields; - return fields; - } - - $scope.clear = function (field) { - return _.mapObject(field, function (key, val) { - if (typeof val === 'object') { - return $scope.clear(val); - } - return undefined; - - }); - }; - - function addNew(preset) { - console.log(preset); - $scope.form.$setDirty(true); - $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || []; - var repeatsection = $scope.model[$scope.options.key]; - var newsection = angular.copy($scope.options.templateOptions.defaultModel); - Object.assign(newsection, preset); - repeatsection.push(newsection); - } + function updateFooterBottom() { - function remove($index) { - $scope.model[$scope.options.key].splice($index, 1); - $scope.form.$setDirty(true); + if ($scope.showDownloaderStatus) { + if ($scope.showAutomaticUpdateFooter) { + $scope.updateFooterBottom = 20; + } else { + $scope.updateFooterBottom = 38; } + } else { + $scope.updateFooterBottom = 0; } - }); + } - formlyConfigProvider.setType({ - name: 'recheckAllCaps', - templateUrl: 'static/html/config/recheck-all-caps.html', - controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) { - $scope.recheck = function (checkType) { - IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) { - //A bit ugly, but we have to update the current model with the new data from the list - for (var i = 0; i < $scope.model.length; i++) { - for (var j = 0; j < listOfResults.length; j++) { - if ($scope.model[i].name === listOfResults[j].indexerConfig.name) { - updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig); - $scope.form.$setDirty(true); - } - } - } - }); - } + function updatePaddingBottom() { + var paddingBottom = 0; + if ($scope.showDownloaderStatus) { + paddingBottom += 30; } - }); - - - formlyConfigProvider.setType({ - name: 'notificationSection', - templateUrl: 'notificationRepeatSection.html', - controller: function ($scope, NotificationService) { - $scope.formOptions = {formState: $scope.formState}; - $scope.addNew = addNew; - $scope.remove = remove; - $scope.copyFields = copyFields; - $scope.eventTypes = []; + if ($scope.showUpdateFooter) { + paddingBottom += 40; + } + $scope.paddingBottom = paddingBottom; + document.getElementById("wrap").classList.remove("padding-bottom-0"); + document.getElementById("wrap").classList.remove("padding-bottom-30"); + document.getElementById("wrap").classList.remove("padding-bottom-40"); + document.getElementById("wrap").classList.remove("padding-bottom-70"); + var paddingBottomClass = "padding-bottom-" + paddingBottom; + document.getElementById("wrap").classList.add(paddingBottomClass); + } - var allData = NotificationService.getAllData(); - _.each(_.keys(allData), function (key) { - $scope.eventTypes.push({"key": key, "label": allData[key].readable}) - }) + updatePaddingBottom(); - function copyFields(fields) { - fields = angular.copy(fields); - $scope.repeatfields = fields; - return fields; - } + updateFooterBottom(); - $scope.clear = function (field) { - return _.mapObject(field, function (key, val) { - if (typeof val === 'object') { - return $scope.clear(val); - } - return undefined; - }); - }; + } +} - function addNew(eventType) { - $scope.form.$setDirty(true); - $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || []; - var repeatsection = $scope.model[$scope.options.key]; - var newsection = angular.copy($scope.options.templateOptions.defaultModel); - var eventTypeData = NotificationService.getAllData()[eventType]; - console.log(eventTypeData); - newsection.eventType = eventType; - newsection.titleTemplate = eventTypeData.titleTemplate; - newsection.bodyTemplate = eventTypeData.bodyTemplate; - newsection.messageType = eventTypeData.messageType; +angular + .module('nzbhydraApp').directive('focusOn', focusOn); - repeatsection.push(newsection); - } +function focusOn() { + return directive; - function remove($index) { - $scope.model[$scope.options.key].splice($index, 1); - $scope.form.$setDirty(true); - } + function directive(scope, elem, attr) { + scope.$on('focusOn', function (e, name) { + if (name === attr.focusOn) { + elem[0].focus(); } }); + } +} - formlyConfigProvider.setType({ - //Button - name: 'testNotification', - templateUrl: 'button-test-notification.html', - controller: function ($scope, NotificationService) { +/* + * (C) Copyright 2017 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. + */ +angular + .module('nzbhydraApp') + .directive('downloaderStatusFooter', downloaderStatusFooter); - //When button is clicked - $scope.testNotification = function () { - NotificationService.testNotification($scope.model.eventType) - } - } - }); +function downloaderStatusFooter() { + controller.$inject = ["$scope", "$http", "RequestsErrorHandler", "HydraAuthService", "$interval", "bootstrapped"]; + return { + templateUrl: 'static/html/directives/downloader-status-footer.html', + controller: controller + }; - formlyConfigProvider.setType({ - name: 'horizontalTestNotification', - extends: 'testNotification', - wrapper: ['settingWrapper', 'bootstrapHasError'] + function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) { + + var downloaderStatus; + var updateInterval = null; + console.log("websocket"); + var socket = new SockJS(bootstrapped.baseUrl + 'websocket'); + var stompClient = Stomp.over(socket); + stompClient.debug = null; + stompClient.connect({}, function (frame) { + stompClient.subscribe('/topic/downloaderStatus', function (message) { + downloaderStatus = JSON.parse(message.body); + updateFooter(downloaderStatus); + }); + stompClient.send("/app/connectDownloaderStatus", function (message) { + downloaderStatus = JSON.parse(message.body); + updateFooter(downloaderStatus); + }) }); - }]); + $scope.$emit("showDownloaderStatus", true); + var downloadRateCounter = 0; + + $scope.downloaderChart = { + options: { + chart: { + type: 'stackedAreaChart', + height: 35, + width: 300, + margin: { + top: 5, + right: 0, + bottom: 0, + left: 0 + }, + x: function (d) { + return d.x; + }, + y: function (d) { + return d.y; + }, + interactive: true, + useInteractiveGuideline: false, + transitionDuration: 0, + showControls: false, + showLegend: false, + showValues: false, + duration: 0, + tooltip: { + valueFormatter: function (d, i) { + return d + " kb/s"; + }, + keyFormatter: function () { + return ""; + }, + id: "downloader-status-tooltip" + }, + css: "float:right;" + } + }, + data: [{values: [], key: "Bla", color: '#00a950'}], + config: { + refreshDataOnly: true, + deepWatchDataDepth: 0, + deepWatchData: false, + deepWatchOptions: false + } + }; + function updateFooter() { + if (downloaderStatus.lastUpdateForNow && updateInterval === null) { + //Server will send no new status updates for a while because the last two retrieved statuses are the same. + //We must still update the footer so that the graph doesn't stand still + console.debug("Retrieved last update for now, starting update interval"); + updateInterval = $interval(function () { + //Just put the last known rate at the end to keep it going + $scope.downloaderChart.data[0].values.splice(0, 1); + $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate}); + try { + $scope.api.update(); + } catch (ignored) { + } + if (_.every($scope.downloaderChart.data[0].values, function (value) { + return value === downloaderStatus.lastDownloadRate + })) { + //The bar has been filled with the latest known value, we can now stop until we get a new update + console.debug("Filled the bar with last known value, stopping update interval"); + $interval.cancel(updateInterval); + updateInterval = null; + } + }, 1000); + } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) { + //New data is incoming, cancel interval + console.debug("Got new update, stopping update interval") + $interval.cancel(updateInterval); + updateInterval = null; + } + $scope.foo = downloaderStatus; + $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo'; + $scope.foo.url = downloaderStatus.url; + //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated + var maxEntriesHistory = 200; + if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) { + //Not yet full, just fill up + console.debug("Adding data, filling bar with initial values") + for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) { + if (i >= downloaderStatus.downloadingRatesInKilobytes.length) { + break; + } + $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]}); + } + } else { + console.debug("Adding data, moving bar") + //Remove first one, add to the end + $scope.downloaderChart.data[0].values.splice(0, 1); + $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate}); + } + try { + $scope.api.update(); + } catch (ignored) { + } + if ($scope.foo.state === "DOWNLOADING") { + $scope.foo.buttonClass = "play"; + } else if ($scope.foo.state === "PAUSED") { + $scope.foo.buttonClass = "pause"; + } else if ($scope.foo.state === "OFFLINE") { + $scope.foo.buttonClass = "off"; + } else { + $scope.foo.buttonClass = "time"; + } + $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase(); + //Bad but without the state isn't updated + $scope.$apply(); + } -ConfigService.$inject = ["$http", "$q", "$cacheFactory", "$uibModal", "bootstrapped", "RequestsErrorHandler"];angular - .module('nzbhydraApp') - .factory('ConfigService', ConfigService); + } +} -function ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) { - ConfigureInModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$http", "growl", "$interval", "RequestsErrorHandler", "localStorageService", "externalTool", "dialogInfo"]; - var cache = $cacheFactory("nzbhydra"); - var safeConfig = bootstrapped.safeConfig; +angular + .module('nzbhydraApp') + .directive('downloadNzbzipButton', downloadNzbzipButton); +function downloadNzbzipButton() { + controller.$inject = ["$scope", "growl", "$http", "FileDownloadService"]; return { - set: set, - get: get, - getSafe: getSafe, - invalidateSafe: invalidateSafe, - maySeeAdminArea: maySeeAdminArea, - reloadConfig: reloadConfig, - apiHelp: apiHelp, - configureIn: configureIn + templateUrl: 'static/html/directives/download-nzbzip-button.html', + require: ['^searchResults'], + scope: { + searchResults: "<", + searchTitle: "<", + callback: "&" + }, + controller: controller }; - function set(newConfig, ignoreWarnings) { - var deferred = $q.defer(); - $http.put('internalapi/config', newConfig) - .then(function (response) { - if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) { - cache.put("config", newConfig); - setTimeout(function () { - invalidateSafe(); - }, 500) - } - deferred.resolve(response); - - }, function (errorresponse) { - console.log("Error saving settings:"); - console.log(errorresponse); - deferred.reject(errorresponse); - }); - return deferred.promise; - } - function reloadConfig() { - return $http.get('internalapi/config/reload').then(function (response) { - return response.data; - }); - } + function controller($scope, growl, $http, FileDownloadService) { + $scope.download = function () { + if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) { + growl.info("You should select at least one result..."); + } else { + var values = _.map($scope.searchResults, function (value) { + return value.searchResultId; + }); + var link = "internalapi/nzbzip"; - function apiHelp() { - return $http.get('internalapi/config/apiHelp').then(function (response) { - return response.data; - }); + var searchTitle; + if (angular.isDefined($scope.searchTitle)) { + searchTitle = " for " + $scope.searchTitle.replace("[^a-zA-Z0-9.-]", "_"); + } else { + searchTitle = ""; + } + var filename = "NZBHydra NZBs" + searchTitle + ".zip"; + $http({method: "post", url: link, data: values}).then(function (response) { + if (response.data.successful && response.data.zip !== null) { + link = "internalapi/nzbzipDownload"; + FileDownloadService.downloadFile(link, filename, "POST", response.data.zipFilepath); + if (angular.isDefined($scope.callback)) { + $scope.callback({result: response.data.addedIds}); + } + if (response.data.missedIds.length > 0) { + growl.error("Unable to add " + response.missedIds.length + " out of " + values.length + " NZBs to ZIP"); + } + } else { + growl.error(response.data.message); + } + }, function (data, status, headers, config) { + growl.error(status); + }); + } + } } +} - function get() { - var config = cache.get("config"); - if (angular.isUndefined(config)) { - config = $http.get('internalapi/config').then(function (response) { - return response.data; - }); - cache.put("config", config); - } - return config; - } +angular + .module('nzbhydraApp') + .directive('downloadNzbsButton', downloadNzbsButton); - function getSafe() { - return safeConfig; - } +function downloadNzbsButton() { + controller.$inject = ["$scope", "$http", "NzbDownloadService", "ConfigService", "growl"]; + return { + templateUrl: 'static/html/directives/download-nzbs-button.html', + require: ['^searchResults'], + scope: { + searchResults: "<", + callback: "&" + }, + controller: controller + }; - function invalidateSafe() { - RequestsErrorHandler.specificallyHandled(function () { - $http.get('internalapi/config/safe').then(function (response) { - safeConfig = response.data; - }); - }); + function controller($scope, $http, NzbDownloadService, ConfigService, growl) { - } + $scope.downloaders = NzbDownloadService.getEnabledDownloaders(); + $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null; - function maySeeAdminArea() { - function loadAll() { - var maySeeAdminArea = cache.get("maySeeAdminArea"); - if (!angular.isUndefined(maySeeAdminArea)) { - var deferred = $q.defer(); - deferred.resolve(maySeeAdminArea); - return deferred.promise; - } + $scope.download = function (downloader) { + if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) { + growl.info("You should select at least one result..."); + } else { - return $http.get('internalapi/mayseeadminarea') - .then(function (configResponse) { - var config = configResponse.data; - cache.put("maySeeAdminArea", config); - return configResponse.data; + var didFilterOutResults = false; + var didKeepAnyResults = false; + var searchResults = _.filter($scope.searchResults, function (value) { + if (value.downloadType === "NZB") { + didKeepAnyResults = true; + return true; + } else { + console.log("Not sending torrent result to downloader"); + didFilterOutResults = true; + return false; + } }); - } + if (didFilterOutResults && !didKeepAnyResults) { + growl.info("None of the selected results were NZBs. Adding aborted"); + if (angular.isDefined($scope.callback)) { + $scope.callback({result: []}); + } + return; + } else if (didFilterOutResults && didKeepAnyResults) { + growl.info("Some the selected results are torrent results which were skipped"); + } - return loadAll().then(function (maySeeAdminArea) { - return maySeeAdminArea; - }); - } + var tos = _.map(searchResults, function (entry) { + return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory} + }); - function configureIn(externalTool) { - $uibModal.open({ - templateUrl: 'static/html/configure-in-modal.html', - controller: ConfigureInModalInstanceCtrl, - size: "md", - resolve: { - externalTool: function () { - return externalTool; - }, - dialogInfo: function () { - return $http.get("internalapi/externalTools/getDialogInfo").then(function (response) { - return response.data; - }) + NzbDownloadService.download(downloader, tos).then(function (response) { + if (angular.isDefined(response.data)) { + if (response !== "dismissed") { + if (response.data.successful) { + if (response.data.message == null) { + growl.info("Successfully added all NZBs"); + } else { + growl.warning(response.data.message); + } + } else { + growl.error(response.data.message); + } + } else { + growl.error("Error while adding NZBs"); + } + if (angular.isDefined($scope.callback)) { + $scope.callback({result: response.data.addedIds}); + } + } + }, function () { + growl.error("Error while adding NZBs"); + }); + } + }; + + $scope.sendToBlackhole = function () { + var didFilterOutResults = false; + var didKeepAnyResults = false; + var searchResults = _.filter($scope.searchResults, function (value) { + if (value.downloadType === "TORRENT") { + didKeepAnyResults = true; + return true; + } else { + console.log("Not sending NZB result to black hole"); + didFilterOutResults = true; + return false; + } + }); + if (didFilterOutResults && !didKeepAnyResults) { + growl.info("None of the selected results were torrents. Adding aborted"); + if (angular.isDefined($scope.callback)) { + $scope.callback({result: []}); } + return; + } else if (didFilterOutResults && didKeepAnyResults) { + growl.info("Some the selected results are NZB results which were skipped"); } - }) + var searchResultIds = _.pluck(searchResults, "searchResultId"); + $http.put("internalapi/saveTorrent", searchResultIds).then(function (response) { + if (response.data.successful) { + growl.info("Successfully saved all torrents"); + } else { + growl.error(response.data.message); + } + if (angular.isDefined($scope.callback)) { + $scope.callback({result: response.data.addedIds}); + } + }); + } + } +} - function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) { - var lastConfig = localStorageService.get(externalTool); - $scope.externalTool = externalTool; - $scope.externalToolDisplayName = externalTool; - $scope.externalToolsMessages = []; - $scope.closeButtonType = "warning"; - $scope.completed = false; - $scope.working = false; - $scope.showMessages = false; - $scope.nzbhydraHost = dialogInfo.nzbhydraHost; - $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured; - $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured; - $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured; - $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured; - $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured; - $scope.addDisabledIndexers = false; +freetextFilter.$inject = ["DebugService"]; +booleanFilter.$inject = ["DebugService"];angular + .module('nzbhydraApp').directive("columnFilterWrapper", columnFilterWrapper); - if (!$scope.configureForUsenet && !$scope.configureForTorrents) { - growl.error("No usenet or torrent indexers configured"); +function columnFilterWrapper() { + controller.$inject = ["$scope", "DebugService"]; + return { + restrict: "E", + templateUrl: 'static/html/dataTable/columnFilterOuter.html', + transclude: true, + controllerAs: 'columnFilterWrapperCtrl', + scope: { + inline: "@" + }, + bindToController: true, + controller: controller, + link: function (scope, element, attr, ctrl) { + scope.element = element; } + }; + function controller($scope, DebugService) { + var vm = this; - $scope.nzbhydraName = "NZBHydra2"; - $scope.xdarrHost = "http://localhost:" - $scope.addType = "SINGLE"; - $scope.enableRss = true; - $scope.enableAutomaticSearch = true; - $scope.enableInteractiveSearch = true; - $scope.categories = null; - $scope.animeCategories = null; - $scope.priority = 0; - $scope.useHydraPriorities = true; + vm.open = false; + vm.isActive = false; - if (externalTool === "Sonarr" || externalTool === "Sonarrv3") { - $scope.xdarrHost += "8989"; - $scope.categories = "5030,5040"; - if (externalTool === "Sonarrv3") { - $scope.externalToolDisplayName = "Sonarr v3+"; - } - } else if (externalTool === "Radarr" || externalTool === "Radarrv3") { - $scope.xdarrHost += "7878"; - $scope.categories = "2000"; - if (externalTool === "Radarrv3") { - $scope.externalToolDisplayName = "Radarr v3+"; + vm.toggle = function () { + vm.open = !vm.open; + if (vm.open) { + $scope.$broadcast("opened"); } - } else if (externalTool === "Lidarr") { - $scope.xdarrHost += "8686"; - $scope.categories = "3000"; - } else if (externalTool === "Readarr") { - $scope.xdarrHost += "8787"; - $scope.categories = "7020,8010"; - } - $scope.removeYearFromSearchString = false; - - if (lastConfig !== null && lastConfig !== undefined) { - Object.assign($scope, lastConfig); - } - - $scope.close = function () { - $uibModalInstance.dismiss(); }; - $scope.submit = function (deleteOnly) { - if ($scope.completed && !deleteOnly) { - $uibModalInstance.dismiss(); - } - if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) { - growl.error("No usenet or torrent indexers configured"); - return; + vm.clear = function () { + if (vm.open) { + $scope.$broadcast("clear"); } - $scope.externalToolsMessages = []; - $scope.spinnerActive = true; - $scope.working = true; - $scope.showMessages = true; - var data = { + }; - nzbhydraName: $scope.nzbhydraName, - externalTool: $scope.externalTool, - nzbhydraHost: $scope.nzbhydraHost, - addType: deleteOnly ? "DELETE_ONLY" : $scope.addType, - xdarrHost: $scope.xdarrHost, - xdarrApiKey: $scope.xdarrApiKey, - enableRss: $scope.enableRss, - enableAutomaticSearch: $scope.enableAutomaticSearch, - enableInteractiveSearch: $scope.enableInteractiveSearch, - categories: $scope.categories, - animeCategories: $scope.animeCategories, - removeYearFromSearchString: $scope.removeYearFromSearchString, - earlyDownloadLimit: $scope.earlyDownloadLimit, - multiLanguages: $scope.multiLanguages, - configureForUsenet: $scope.configureForUsenet, - configureForTorrents: $scope.configureForTorrents, - additionalParameters: $scope.additionalParameters, - minimumSeeders: $scope.minimumSeeders, - seedRatio: $scope.seedRatio, - seedTime: $scope.seedTime, - seasonPackSeedTime: $scope.seasonPackSeedTime, - discographySeedTime: $scope.discographySeedTime, - addDisabledIndexers: $scope.addDisabledIndexers, - priority: $scope.priority, - useHydraPriorities: $scope.useHydraPriorities - } + $scope.$on("filter", function (event, column, filterModel, isActive, open) { + vm.open = open || false; + vm.isActive = isActive; + }); - localStorageService.set(externalTool, data); + DebugService.log("filter-wrapper"); + } - function updateMessages() { - $http.get("internalapi/externalTools/messages").then(function (response) { - $scope.externalToolsMessages = response.data; - }); - } +} - var updateInterval = $interval(function () { - updateMessages(); - }, 500); - RequestsErrorHandler.specificallyHandled(function () { - $scope.completed = false; - $http.post("internalapi/externalTools/configure", data).then(function (response) { - updateMessages(); - $interval.cancel(updateInterval); - $scope.spinnerActive = false; - console.log(response); - if (response.data) { - $scope.completed = true; - $scope.closeButtonType = "success"; - } else { - $scope.working = false; - $scope.completed = false; - } - }, function (error) { - updateMessages(); - console.error(error.data); - $interval.cancel(updateInterval); - $scope.completed = false; - $scope.spinnerActive = false; - $scope.working = false; - }); - }); - }; +angular + .module('nzbhydraApp').directive("freetextFilter", freetextFilter); + +function freetextFilter(DebugService) { + controller.$inject = ["$scope", "focus"]; + return { + template: '', + require: "^columnFilterWrapper", + controllerAs: 'innerController', + scope: { + column: "@", + onKey: "@", + placeholder: "@", + tooltip: "@" + }, + controller: controller + }; + + function controller($scope, focus) { + $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper + $scope.data = {}; + $scope.tooltip = $scope.tooltip || ""; + + $scope.$on("opened", function () { + focus("freetext-filter-input"); + }); + + function emitFilterEvent(isOpen) { + isOpen = $scope.inline || isOpen; + $scope.$emit("filter", $scope.column, { + filterValue: $scope.data.filter, + filterType: "freetext" + }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen); + } + + $scope.$on("clear", function () { + //Don't clear but close window (event is fired when clicked outside) + emitFilterEvent(false); + }); + $scope.onKeyUp = function (keyEvent) { + if (keyEvent.which === 13 || $scope.onKey) { + emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed + } + }; + DebugService.log("filter-freetext"); } } -/* - * (C) Copyright 2017 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. - */ - -ConfigFields.$inject = ["$injector"]; angular - .module('nzbhydraApp') - .factory('ConfigFields', ConfigFields); + .module('nzbhydraApp').directive("checkboxesFilter", checkboxesFilter); -function ConfigFields($injector) { +function checkboxesFilter() { + controller.$inject = ["$scope", "DebugService"]; return { - getFields: getFields + template: '', + controllerAs: 'checkboxesFilterController', + scope: { + column: "@", + entries: "<", + preselect: "<", + showInvert: "<", + isBoolean: "<" + }, + controller: controller }; - function ipValidator() { - return { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - if (value) { - return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value) - || /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(value); - } - return true; - }, - message: '$viewValue + " is not a valid IP Address"' + function controller($scope, DebugService) { + $scope.selected = { + entries: [] }; - } + $scope.active = false; - function regexValidator(regex, message, prefixViewValue, preventEmpty) { - return { - expression: function ($viewValue, $modelValue) { - var value = $modelValue || $viewValue; - if (value) { - if (Array.isArray(value)) { - for (var i = 0; i < value.length; i++) { - if (!regex.test(value[i])) { - return false; - } - } - return true; - } else { - return regex.test(value); - } - } - return !preventEmpty; - }, - message: (prefixViewValue ? '$viewValue + " ' : '" ') + message + '"' + if ($scope.preselect) { + $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries); + } + + $scope.invert = function () { + $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries); }; - } - function getFields(rootModel, showAdvanced) { - return { - main: [ - { - wrapper: 'fieldset', - templateOptions: {label: 'Hosting'}, - fieldGroup: [ - { - key: 'host', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Host', - required: true, - placeholder: 'IPv4 address to bind to', - help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.' - }, - validators: { - ipAddress: ipValidator() - } - }, - { - key: 'port', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Port', - required: true, - placeholder: '5076', - help: 'Requires restart.' - }, - validators: { - port: regexValidator(/^\d{1,5}$/, "is no valid port", true) - } - }, - { - key: 'urlBase', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'URL base', - placeholder: '/nzbhydra', - help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.', - tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like "/nzbhydra". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.', - advanced: true - }, - validators: { - urlBase: regexValidator(/^((\/.*[^\/])|\/)$/, 'URL base has to start and may not end with /', false, true) - } + $scope.selectAll = function () { + $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries); + }; - }, - { - key: 'ssl', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Use SSL', - help: 'Requires restart.', - tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\'s more secure and can be configured better.', - advanced: true - } - }, - { - key: 'sslKeyStore', - hideExpression: '!model.ssl', - type: 'fileInput', - templateOptions: { - label: 'SSL keystore file', - required: true, - type: "file", - help: 'Requires restart. See wiki.' - } - }, - { - key: 'sslKeyStorePassword', - hideExpression: '!model.ssl', - type: 'horizontalInput', - templateOptions: { - type: 'password', - label: 'SSL keystore password', - required: true, - help: 'Requires restart.' - } - } + $scope.deselectAll = function () { + $scope.selected.entries.splice(0, $scope.selected.entries.length); + }; + $scope.apply = function () { + $scope.active = $scope.selected.entries.length < $scope.entries.length; + $scope.$emit("filter", $scope.column, { + filterValue: _.pluck($scope.selected.entries, "id"), + filterType: "checkboxes", + isBoolean: $scope.isBoolean + }, $scope.active) + }; + $scope.clear = function () { + $scope.selectAll(); + $scope.active = false; + $scope.$emit("filter", $scope.column, { + filterValue: undefined, + filterType: "checkboxes", + isBoolean: $scope.isBoolean + }, $scope.active) + }; + $scope.$on("clear", $scope.clear); + DebugService.log("filter-checkboxes"); + } +} - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Proxy', - tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.', - advanced: true - } - , - fieldGroup: [ - { - key: 'proxyType', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'Use proxy', - options: [ - {name: 'None', value: 'NONE'}, - {name: 'SOCKS', value: 'SOCKS'}, - {name: 'HTTP(S)', value: 'HTTP'} - ] - } - }, - { - key: 'proxyHost', - type: 'horizontalInput', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'text', - label: 'SOCKS proxy host', - placeholder: 'Set to use a SOCKS proxy', - help: "IPv4 only" - } - }, - { - key: 'proxyPort', - type: 'horizontalInput', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'number', - label: 'Proxy port', - placeholder: '1080' - } - }, - { - key: 'proxyUsername', - type: 'horizontalInput', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'text', - label: 'Proxy username' - } - }, - { - key: 'proxyPassword', - type: 'passwordSwitch', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'text', - label: 'Proxy password' - } - }, - { - key: 'proxyIgnoreLocal', - type: 'horizontalSwitch', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'switch', - label: 'Bypass local network addresses' - } - }, - { - key: 'proxyIgnoreDomains', - type: 'horizontalChips', - hideExpression: 'model.proxyType==="NONE"', - templateOptions: { - type: 'text', - help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.', - label: 'Bypass domains' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: {label: 'UI'}, - fieldGroup: [ +angular + .module('nzbhydraApp').directive("booleanFilter", booleanFilter); - { - key: 'theme', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'Theme', - options: [ - {name: 'Auto', value: 'auto'}, - {name: 'Grey', value: 'grey'}, - {name: 'Bright', value: 'bright'}, - {name: 'Dark', value: 'dark'} - ] - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: {label: 'Security'}, - fieldGroup: [ - { - key: 'apiKey', - type: 'horizontalApiKeyInput', - templateOptions: { - label: 'API key', - help: 'Alphanumeric only.', - required: true - }, - validators: { - apiKey: regexValidator(/^[a-zA-Z0-9]*$/, "API key must only contain numbers and digits", false) - } - }, - { - key: 'dereferer', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Dereferer', - help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.', - advanced: true - } - }, - { - key: 'verifySsl', - type: 'horizontalSwitch', - templateOptions: { - label: 'Verify SSL certificates', - help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.', - advanced: true - } - }, - { - key: 'verifySslDisabledFor', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Disable SSL for...', - help: 'Add hosts for which to disable SSL verification. Apply words with return key.', - advanced: true - } - }, - { - key: 'disableSslLocally', - type: 'horizontalSwitch', - templateOptions: { - type: 'text', - label: 'Disable SSL locally', - help: 'Disable SSL for local hosts.', - advanced: true - } - }, - { - key: 'sniDisabledFor', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Disable SNI', - help: 'Add a host if you get an "unrecognized_name" error. Apply words with return key. See wiki.', - advanced: true - } - }, - { - key: 'useCsrf', - type: 'horizontalSwitch', - templateOptions: { - label: 'Use CSRF protection', - help: 'Use CSRF protection.', - advanced: true - } - } - ] - }, +function booleanFilter(DebugService) { + controller.$inject = ["$scope"]; + return { + template: '', + controllerAs: 'booleanFilterController', + scope: { + column: "@", + options: "<", + preselect: "@" + }, + controller: controller + }; - { - wrapper: 'fieldset', - key: 'logging', - templateOptions: { - label: 'Logging', - tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.', - advanced: true - }, - fieldGroup: [ - { - key: 'logfilelevel', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'Logfile level', - options: [ - {name: 'Error', value: 'ERROR'}, - {name: 'Warning', value: 'WARN'}, - {name: 'Info', value: 'INFO'}, - {name: 'Debug', value: 'DEBUG'} - ], - help: 'Takes effect on next restart.' - } - }, - { - key: 'logMaxHistory', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Max log history', - help: 'How many daily log files will be kept.' - } - }, - { - key: 'consolelevel', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'Console log level', - options: [ - {name: 'Error', value: 'ERROR'}, - {name: 'Warning', value: 'WARN'}, - {name: 'Info', value: 'INFO'}, - {name: 'Debug', value: 'DEBUG'} - ], - help: 'Takes effect on next restart.' - } - }, - { - key: 'logGc', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Log GC', - help: 'Enable garbage collection logging. Only for debugging of memory issues.' - } - }, - { - key: 'logIpAddresses', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Log IP addresses' - } - }, - { - key: 'mapIpToHost', - type: 'horizontalSwitch', - hideExpression: '!model.logIpAddresses', - templateOptions: { - type: 'switch', - label: 'Map hosts', - help: 'Try to map logged IP addresses to host names.', - tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.' - } - }, - { - key: 'logUsername', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Log user names' - } - }, - { - key: 'markersToLog', - type: 'horizontalMultiselect', - hideExpression: 'model.consolelevel !== "DEBUG" && model.logfilelevel !== "DEBUG"', - templateOptions: { - label: 'Log markers', - help: 'Select certain sections for more output on debug level. Please enable only when asked for.', - options: [ - {label: 'API limits', id: 'LIMITS'}, - {label: 'Category mapping', id: 'CATEGORY_MAPPING'}, - {label: 'Config file handling', id: 'CONFIG_READ_WRITE'}, - {label: 'Custom mapping', id: 'CUSTOM_MAPPING'}, - {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'}, - {label: 'Duplicate detection', id: 'DUPLICATES'}, - {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'}, - {label: 'History cleanup', id: 'HISTORY_CLEANUP'}, - {label: 'HTTP', id: 'HTTP'}, - {label: 'HTTPS', id: 'HTTPS'}, - {label: 'HTTP Server', id: 'SERVER'}, - {label: 'Indexer scheduler', id: 'SCHEDULER'}, - {label: 'Notifications', id: 'NOTIFICATIONS'}, - {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'}, - {label: 'Performance', id: 'PERFORMANCE'}, - {label: 'Rejected results', id: 'RESULT_ACCEPTOR'}, - {label: 'Removed trailing words', id: 'TRAILING'}, - {label: 'URL calculation', id: 'URL_CALCULATION'}, - {label: 'User agent mapping', id: 'USER_AGENT'}, - {label: 'VIP expiry', id: 'VIP_EXPIRY'} - ], - buttonText: "None" - } - }, - { - key: 'historyUserInfoType', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'History user info', - options: [ - {name: 'IP and username', value: 'BOTH'}, - {name: 'IP address', value: 'IP'}, - {name: 'Username', value: 'USERNAME'}, - {name: 'None', value: 'NONE'} - ], - help: 'Only affects if value is displayed in the search/download history.', - hideExpression: '!model.keepHistory' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Backup', - advanced: true - }, - fieldGroup: [ - { - key: 'backupFolder', - type: 'horizontalInput', - templateOptions: { - label: 'Backup folder', - help: 'Either relative to the NZBHydra data folder or an absolute folder.' - } - }, - { - key: 'backupEveryXDays', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Backup every...', - addonRight: { - text: 'days' - } - } - }, - { - key: 'backupBeforeUpdate', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Backup before update' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: {label: 'Updates'}, - fieldGroup: [ - { - key: 'updateAutomatically', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Install updates automatically' - } - }, { - key: 'updateToPrereleases', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Install prereleases', - advanced: true - } - }, - { - key: 'deleteBackupsAfterWeeks', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Delete backups after...', - addonRight: { - text: 'weeks' - }, - advanced: true - } - }, - { - key: 'showUpdateBannerOnDocker', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Show update banner when managed externally', - advanced: true, - help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\'t let NZBHydra update itself).' - } - }, - { - key: 'showWhatsNewBanner', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Show info banner after automatic updates', - help: 'Please keep it enabled, I put some effort into the changelog ;-)', - advanced: true - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'History', - advanced: true - }, - fieldGroup: [ - { - key: 'keepHistory', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Keep history', - help: 'Controls search and download history.', - tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.' - } - }, - { - key: 'keepHistoryForWeeks', - type: 'horizontalInput', - hideExpression: '!model.keepHistory', - templateOptions: { - type: 'number', - label: 'Keep history for...', - addonRight: { - text: 'weeks' - }, - min: 1, - help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.' - } - }, - { - key: 'keepStatsForWeeks', - type: 'horizontalInput', - hideExpression: '!model.keepHistory', - templateOptions: { - type: 'number', - label: 'Keep stats for...', - addonRight: { - text: 'weeks' - }, - min: 1, - help: 'Only keep stats for a certain time. Will decrease database size.' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Database', - tooltip: 'You should not change these values unless you\'re either told to or really know what you\'re doing.', - advanced: true - }, - fieldGroup: [ - { - key: 'databaseCompactTime', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Database compact time', - addonRight: { - text: 'ms' - }, - min: 200, - help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.' - } - }, - { - key: 'databaseRetentionTime', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Database retention time', - addonRight: { - text: 'ms' - }, - help: 'How long the db should retain old, persisted data. See here.' - } - }, - { - key: 'databaseWriteDelay', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Database write delay', - addonRight: { - text: 'ms' - }, - help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.' - } - } - - ] - }, - { - wrapper: 'fieldset', - templateOptions: {label: 'Other'}, - fieldGroup: [ - { - key: 'startupBrowser', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Open browser on startup' - } - }, - { - key: 'showNews', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Show news', - help: "Hydra will occasionally show news when opened. You can always find them in the system section", - advanced: true - } - }, - { - key: 'proxyImages', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Proxy images', - help: 'Download images from indexers and info providers (e.g. TMBD) and serve them via NZBHydra. Will only affect searches via UI, not API searches.' - } - }, - { - key: 'checkOpenPort', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Check for open port', - help: "Check if NZBHydra is reachable from the internet and not protected", - advanced: true - } - }, - { - key: 'xmx', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'JVM memory', - addonRight: { - text: 'MB' - }, - min: 128, - help: '256 should suffice except when working with big databases / many indexers. See wiki.', - advanced: true - } - } - ] - - } - ], - - searching: [ - { - wrapper: 'fieldset', - templateOptions: { - label: 'Indexer access', - tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.', - advanced: true - }, - fieldGroup: [ - { - key: 'timeout', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Timeout when accessing indexers', - help: 'Any web call to an indexer taking longer than this is aborted.', - min: 1, - addonRight: { - text: 'seconds' - } - } - }, - { - key: 'userAgent', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'User agent', - help: 'Used when accessing indexers.', - required: true, - tooltip: 'Some indexers don\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.', - } - }, - { - key: 'userAgents', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Map user agents', - help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.', - } - }, - { - key: 'ignoreLoadLimitingForInternalSearches', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Ignore load limiting internally', - help: 'When enabled load limiting defined for indexers will be ignored for internal searches.', - } - }, - { - key: 'ignoreTemporarilyDisabled', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Ignore temporary errors', - tooltip: "By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.", - } - } - ] - }, { - wrapper: 'fieldset', - templateOptions: { - label: 'Category handling', - tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).', - advanced: true - }, - fieldGroup: [ - - { - key: 'transformNewznabCategories', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Transform newznab categories', - help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.' - } - }, - { - key: 'sendTorznabCategories', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Send categories to trackers', - help: 'If disabled no categories will be included in queries to torznab indexers (trackers).' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Media IDs / Query generation / Query processing', - tooltip: 'Raw search engines like Binsearch don\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\'s or show\'s title and generate a query, for example "showname s01e01". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.' - }, - fieldGroup: [ - { - key: 'alwaysConvertIds', - type: 'horizontalSelect', - templateOptions: { - label: 'Convert media IDs for...', - options: [ - {name: 'Internal searches', value: 'INTERNAL'}, - {name: 'API searches', value: 'API'}, - {name: 'All searches', value: 'BOTH'}, - {name: 'Never', value: 'NONE'} - ], - help: "When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).", - advanced: true - } - }, - { - key: 'generateQueries', - type: 'horizontalSelect', - templateOptions: { - label: 'Generate queries', - options: [ - {name: 'Internal searches', value: 'INTERNAL'}, - {name: 'API searches', value: 'API'}, - {name: 'All searches', value: 'BOTH'}, - {name: 'Never', value: 'NONE'} - ], - help: "Generate queries for indexers which do not support ID based searches." - } - }, - { - key: 'idFallbackToQueryGeneration', - type: 'horizontalSelect', - templateOptions: { - label: 'Fallback to generated queries', - options: [ - {name: 'Internal searches', value: 'INTERNAL'}, - {name: 'API searches', value: 'API'}, - {name: 'All searches', value: 'BOTH'}, - {name: 'Never', value: 'NONE'} - ], - help: "When no results were found for a query ID search again using a generated query (on indexer level)." - } - }, - { - key: 'language', - type: 'horizontalSelect', - templateOptions: { - type: 'text', - label: 'Language', - required: true, - help: 'Used for movie query generation and autocomplete only.', - options: [{"name": "Abkhaz", value: "ab"}, { - "name": "Afar", - value: "aa" - }, {"name": "Afrikaans", value: "af"}, {"name": "Akan", value: "ak"}, { - "name": "Albanian", - value: "sq" - }, {"name": "Amharic", value: "am"}, { - "name": "Arabic", - value: "ar" - }, {"name": "Aragonese", value: "an"}, {"name": "Armenian", value: "hy"}, { - "name": "Assamese", - value: "as" - }, {"name": "Avaric", value: "av"}, {"name": "Avestan", value: "ae"}, { - "name": "Aymara", - value: "ay" - }, {"name": "Azerbaijani", value: "az"}, { - "name": "Bambara", - value: "bm" - }, {"name": "Bashkir", value: "ba"}, { - "name": "Basque", - value: "eu" - }, {"name": "Belarusian", value: "be"}, {"name": "Bengali", value: "bn"}, { - "name": "Bihari", - value: "bh" - }, {"name": "Bislama", value: "bi"}, { - "name": "Bosnian", - value: "bs" - }, {"name": "Breton", value: "br"}, {"name": "Bulgarian", value: "bg"}, { - "name": "Burmese", - value: "my" - }, {"name": "Catalan", value: "ca"}, { - "name": "Chamorro", - value: "ch" - }, {"name": "Chechen", value: "ce"}, {"name": "Chichewa", value: "ny"}, { - "name": "Chinese", - value: "zh" - }, {"name": "Chuvash", value: "cv"}, { - "name": "Cornish", - value: "kw" - }, {"name": "Corsican", value: "co"}, {"name": "Cree", value: "cr"}, { - "name": "Croatian", - value: "hr" - }, {"name": "Czech", value: "cs"}, {"name": "Danish", value: "da"}, { - "name": "Divehi", - value: "dv" - }, {"name": "Dutch", value: "nl"}, { - "name": "Dzongkha", - value: "dz" - }, {"name": "English", value: "en"}, { - "name": "Esperanto", - value: "eo" - }, {"name": "Estonian", value: "et"}, {"name": "Ewe", value: "ee"}, { - "name": "Faroese", - value: "fo" - }, {"name": "Fijian", value: "fj"}, {"name": "Finnish", value: "fi"}, { - "name": "French", - value: "fr" - }, {"name": "Fula", value: "ff"}, { - "name": "Galician", - value: "gl" - }, {"name": "Georgian", value: "ka"}, {"name": "German", value: "de"}, { - "name": "Greek", - value: "el" - }, {"name": "Guaraní", value: "gn"}, { - "name": "Gujarati", - value: "gu" - }, {"name": "Haitian", value: "ht"}, {"name": "Hausa", value: "ha"}, { - "name": "Hebrew", - value: "he" - }, {"name": "Herero", value: "hz"}, { - "name": "Hindi", - value: "hi" - }, {"name": "Hiri Motu", value: "ho"}, { - "name": "Hungarian", - value: "hu" - }, {"name": "Interlingua", value: "ia"}, { - "name": "Indonesian", - value: "id" - }, {"name": "Interlingue", value: "ie"}, { - "name": "Irish", - value: "ga" - }, {"name": "Igbo", value: "ig"}, {"name": "Inupiaq", value: "ik"}, { - "name": "Ido", - value: "io" - }, {"name": "Icelandic", value: "is"}, { - "name": "Italian", - value: "it" - }, {"name": "Inuktitut", value: "iu"}, {"name": "Japanese", value: "ja"}, { - "name": "Javanese", - value: "jv" - }, {"name": "Kalaallisut", value: "kl"}, { - "name": "Kannada", - value: "kn" - }, {"name": "Kanuri", value: "kr"}, {"name": "Kashmiri", value: "ks"}, { - "name": "Kazakh", - value: "kk" - }, {"name": "Khmer", value: "km"}, { - "name": "Kikuyu", - value: "ki" - }, {"name": "Kinyarwanda", value: "rw"}, {"name": "Kyrgyz", value: "ky"}, { - "name": "Komi", - value: "kv" - }, {"name": "Kongo", value: "kg"}, {"name": "Korean", value: "ko"}, { - "name": "Kurdish", - value: "ku" - }, {"name": "Kwanyama", value: "kj"}, { - "name": "Latin", - value: "la" - }, {"name": "Luxembourgish", value: "lb"}, { - "name": "Ganda", - value: "lg" - }, {"name": "Limburgish", value: "li"}, {"name": "Lingala", value: "ln"}, { - "name": "Lao", - value: "lo" - }, {"name": "Lithuanian", value: "lt"}, { - "name": "Luba-Katanga", - value: "lu" - }, {"name": "Latvian", value: "lv"}, {"name": "Manx", value: "gv"}, { - "name": "Macedonian", - value: "mk" - }, {"name": "Malagasy", value: "mg"}, { - "name": "Malay", - value: "ms" - }, {"name": "Malayalam", value: "ml"}, {"name": "Maltese", value: "mt"}, { - "name": "Māori", - value: "mi" - }, {"name": "Marathi", value: "mr"}, { - "name": "Marshallese", - value: "mh" - }, {"name": "Mongolian", value: "mn"}, {"name": "Nauru", value: "na"}, { - "name": "Navajo", - value: "nv" - }, {"name": "Northern Ndebele", value: "nd"}, { - "name": "Nepali", - value: "ne" - }, {"name": "Ndonga", value: "ng"}, { - "name": "Norwegian Bokmål", - value: "nb" - }, {"name": "Norwegian Nynorsk", value: "nn"}, { - "name": "Norwegian", - value: "no" - }, {"name": "Nuosu", value: "ii"}, { - "name": "Southern Ndebele", - value: "nr" - }, {"name": "Occitan", value: "oc"}, { - "name": "Ojibwe", - value: "oj" - }, {"name": "Old Church Slavonic", value: "cu"}, {"name": "Oromo", value: "om"}, { - "name": "Oriya", - value: "or" - }, {"name": "Ossetian", value: "os"}, {"name": "Panjabi", value: "pa"}, { - "name": "Pāli", - value: "pi" - }, {"name": "Persian", value: "fa"}, { - "name": "Polish", - value: "pl" - }, {"name": "Pashto", value: "ps"}, { - "name": "Portuguese", - value: "pt" - }, {"name": "Quechua", value: "qu"}, {"name": "Romansh", value: "rm"}, { - "name": "Kirundi", - value: "rn" - }, {"name": "Romanian", value: "ro"}, { - "name": "Russian", - value: "ru" - }, {"name": "Sanskrit", value: "sa"}, {"name": "Sardinian", value: "sc"}, { - "name": "Sindhi", - value: "sd" - }, {"name": "Northern Sami", value: "se"}, { - "name": "Samoan", - value: "sm" - }, {"name": "Sango", value: "sg"}, {"name": "Serbian", value: "sr"}, { - "name": "Gaelic", - value: "gd" - }, {"name": "Shona", value: "sn"}, {"name": "Sinhala", value: "si"}, { - "name": "Slovak", - value: "sk" - }, {"name": "Slovene", value: "sl"}, { - "name": "Somali", - value: "so" - }, {"name": "Southern Sotho", value: "st"}, { - "name": "Spanish", - value: "es" - }, {"name": "Sundanese", value: "su"}, {"name": "Swahili", value: "sw"}, { - "name": "Swati", - value: "ss" - }, {"name": "Swedish", value: "sv"}, {"name": "Tamil", value: "ta"}, { - "name": "Telugu", - value: "te" - }, {"name": "Tajik", value: "tg"}, { - "name": "Thai", - value: "th" - }, {"name": "Tigrinya", value: "ti"}, { - "name": "Tibetan Standard", - value: "bo" - }, {"name": "Turkmen", value: "tk"}, {"name": "Tagalog", value: "tl"}, { - "name": "Tswana", - value: "tn" - }, {"name": "Tonga", value: "to"}, {"name": "Turkish", value: "tr"}, { - "name": "Tsonga", - value: "ts" - }, {"name": "Tatar", value: "tt"}, { - "name": "Twi", - value: "tw" - }, {"name": "Tahitian", value: "ty"}, { - "name": "Uyghur", - value: "ug" - }, {"name": "Ukrainian", value: "uk"}, {"name": "Urdu", value: "ur"}, { - "name": "Uzbek", - value: "uz" - }, {"name": "Venda", value: "ve"}, { - "name": "Vietnamese", - value: "vi" - }, {"name": "Volapük", value: "vo"}, {"name": "Walloon", value: "wa"}, { - "name": "Welsh", - value: "cy" - }, {"name": "Wolof", value: "wo"}, { - "name": "Western Frisian", - value: "fy" - }, {"name": "Xhosa", value: "xh"}, {"name": "Yiddish", value: "yi"}, { - "name": "Yoruba", - value: "yo" - }, {"name": "Zhuang", value: "za"}, {"name": "Zulu", value: "zu"}] - } - }, - { - key: 'replaceUmlauts', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Replace umlauts', - help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Result filters', - tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: "ea" matches "something.from.ea" but not "release.from.other". "web-dl" matches "title.web-dl" and "someweb-dl".' - }, - fieldGroup: [ - { - key: 'applyRestrictions', - type: 'horizontalSelect', - templateOptions: { - label: 'Apply word filters', - options: [ - {name: 'All searches', value: 'BOTH'}, - {name: 'Internal searches', value: 'INTERNAL'}, - {name: 'API searches', value: 'API'}, - {name: 'Never', value: 'NONE'} - ], - help: "For which type of search word/regex filters will be applied" - } - }, - { - key: 'forbiddenWords', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Forbidden words', - help: "Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.", - tooltip: 'One forbidden word in a result title dismisses the result.' - }, - hideExpression: function () { - return rootModel.searching.applyRestrictions === "NONE"; - } - }, - { - key: 'forbiddenRegex', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Forbidden regex', - help: 'Must not be present in a title (case is ignored).', - advanced: true - }, - hideExpression: function () { - return rootModel.searching.applyRestrictions === "NONE"; - } - }, - { - key: 'requiredWords', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Required words', - help: "Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.", - tooltip: 'If any of the required words is not found anywhere in a result title it\'s also dismissed.' - }, - hideExpression: function () { - return rootModel.searching.applyRestrictions === "NONE"; - } - }, - { - key: 'requiredRegex', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Required regex', - help: 'Must be present in a title (case is ignored).', - advanced: true - }, - hideExpression: function () { - return rootModel.searching.applyRestrictions === "NONE"; - } - }, - - { - key: 'forbiddenGroups', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Forbidden groups', - help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.', - advanced: true - }, - hideExpression: function () { - return rootModel.searching.applyRestrictions === "NONE"; - } - }, - { - key: 'forbiddenPosters', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Forbidden posters', - help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.', - advanced: true - } - }, - { - key: 'languagesToKeep', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Languages to keep', - help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.' - } - }, - { - key: 'maxAge', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Maximum results age', - help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.', - addonRight: { - text: 'days' - } - } - }, - { - key: 'minSeeders', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Minimum # seeders', - help: 'Torznab results with fewer seeders will be ignored.' - } - }, - { - key: 'ignorePassworded', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Ignore passworded releases', - help: "Not all indexers provide this information", - tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\'re actually passworded.' - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Result processing' - }, - fieldGroup: [ - { - key: 'wrapApiErrors', - type: 'horizontalSwitch', - templateOptions: { - type: 'text', - label: 'Wrap API errors in empty results page', - help: 'When enabled accessing tools will think the search was completed successfully but without results.', - tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\'t return a result. That way Hydra won\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.', - advanced: true - } - }, - { - key: 'removeTrailing', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Remove trailing...', - help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards ("*"). Apply words with return key.', - tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.' - } - }, - { - key: 'useOriginalCategories', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Use original categories', - help: 'Enable to use the category descriptions provided by the indexer.', - tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.', - advanced: true - } - } - ] - }, - { - type: 'repeatSection', - key: 'customMappings', - model: rootModel.searching, - templateOptions: { - tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.', - btnText: 'Add new custom mapping', - altLegendText: 'Mapping', - headline: 'Custom mappings of queries, search titles and result titles', - advanced: true, - fields: [ - { - key: 'affectedValue', - type: 'horizontalSelect', - templateOptions: { - label: 'Affected value', - options: [ - {name: 'Query', value: 'QUERY'}, - {name: 'Search title', value: 'TITLE'}, - {name: 'Result title', value: 'RESULT_TITLE'}, - ], - required: true, - help: "Determines which value of the search request or result will be processed" - } - }, - { - key: 'searchType', - type: 'horizontalSelect', - hideExpression: 'model.affectedValue === "RESULT_TITLE"', - 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: 'matchAll', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Match whole string', - help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\'s only part of the affected value.' - } - }, - { - key: 'from', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Input pattern', - help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). 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', - required: true, - 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). Use <remove> to remove the match.' - } - }, - { - type: 'customMappingTest', - } - ], - defaultModel: { - searchType: null, - affectedValue: null, - matchAll: true, - from: null, - to: null - } - } - }, - - - { - wrapper: 'fieldset', - templateOptions: { - label: 'Result display' - }, - fieldGroup: [ - { - key: 'loadAllCachedOnInternal', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Display all retrieved results', - help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.', - advanced: true - } - }, - { - key: 'loadLimitInternal', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Display...', - addonRight: { - text: 'results per page' - }, - max: 500, - required: true, - help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.', - advanced: true - } - }, - { - key: 'coverSize', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Cover width', - addonRight: { - text: 'px' - }, - required: true, - help: 'Determines width of covers in search results (when enabled in display options).' - } - } - ] - }, { - wrapper: 'fieldset', - templateOptions: { - label: 'Quick filters' - }, - fieldGroup: [ - { - key: 'showQuickFilterButtons', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Show quick filters', - help: 'Show quick filter buttons for movie and TV results.' - } - }, - { - key: 'alwaysShowQuickFilterButtons', - type: 'horizontalSwitch', - hideExpression: '!model.showQuickFilterButtons', - templateOptions: { - type: 'switch', - label: 'Always show quick filters', - help: 'Show all quick filter buttons for all types of searches.', - advanced: true - } - }, - { - key: 'customQuickFilterButtons', - type: 'horizontalChips', - hideExpression: '!model.showQuickFilterButtons', - templateOptions: { - type: 'text', - label: 'Custom quick filters', - help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Surround with / to mark as a regex. Apply values with enter key.', - tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name "WEB" to be displayed that searches for "webdl" and "web-dl" in lowercase search results.', - advanced: true - } - }, - { - key: 'preselectQuickFilterButtons', - type: 'horizontalMultiselect', - hideExpression: '!model.showQuickFilterButtons', - templateOptions: { - label: 'Preselect quickfilters', - help: 'Choose which quickfilters will be selected by default.', - options: [ - {id: 'source|camts', label: 'CAM / TS'}, - {id: 'source|tv', label: 'TV'}, - {id: 'source|web', label: 'WEB'}, - {id: 'source|dvd', label: 'DVD'}, - {id: 'source|bluray', label: 'Blu-Ray'}, - {id: 'quality|q480p', label: '480p'}, - {id: 'quality|q720p', label: '720p'}, - {id: 'quality|q1080p', label: '1080p'}, - {id: 'quality|q2160p', label: '2160p'}, - {id: 'other|q3d', label: '3D'}, - {id: 'other|qx265', label: 'x265'}, - {id: 'other|qhevc', label: 'HEVC'}, - ], - optionsFunction: function (model) { - var customQuickFilters = []; - _.each(model.customQuickFilterButtons, function (entry) { - var split1 = entry.split("="); - var displayName = split1[0]; - customQuickFilters.push({id: "custom|" + displayName, label: displayName}) - }) - return customQuickFilters; - }, - tooltip: 'To select custom quickfilters you just entered please save the config first.', - buttonText: "None", - advanced: true - } - } - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Duplicate detection', - tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.', - advanced: true - }, - fieldGroup: [ - { - key: 'duplicateSizeThresholdInPercent', - type: 'horizontalPercentInput', - templateOptions: { - type: 'text', - label: 'Duplicate size threshold', - required: true, - addonRight: { - text: '%' - } - - } - }, - { - key: 'duplicateAgeThreshold', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Duplicate age threshold', - required: true, - addonRight: { - text: 'hours' - } - } - } - - ] - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Other', - advanced: true - }, - fieldGroup: [ - { - key: 'keepSearchResultsForDays', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Store results for ...', - addonRight: { - text: 'days' - }, - required: true, - tooltip: 'Found results are stored in the database for this long until they\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).' - } - }, { - key: 'historyForSearching', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Recet searches in search bar', - required: true, - tooltip: 'The number of recent searches shown in the search bar dropdown (the icon).' - } - }, - { - key: 'globalCacheTimeMinutes', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Results cache time', - help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.', - addonRight: { - text: 'minutes' - } - } - } - ] - } - ], - - categoriesConfig: [ - { - key: 'enableCategorySizes', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Category sizes', - help: "Preset min and max sizes depending on the selected category", - tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.' - } - }, - { - key: 'defaultCategory', - type: 'horizontalSelect', - templateOptions: { - label: 'Default category', - options: [], - help: "Set a default category. Reload page to set a category you just added." - }, - controller: function ($scope) { - var options = []; - options.push({name: 'All', value: 'All'}); - _.each($scope.model.categories, function (cat) { - options.push({name: cat.name, value: cat.name}); - }); - $scope.to.options = options; - } - }, - { - type: 'help', - templateOptions: { - type: 'help', - lines: [ - "The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.", - "Restrictions will taken from a result's category, not the search request category which may not always be the same." - ], - marginTop: '50px', - advanced: true - } - }, - { - type: 'repeatSection', - key: 'categories', - model: rootModel.categoriesConfig, - templateOptions: { - btnText: 'Add new category', - headline: 'Categories', - advanced: true, - fields: [ - { - key: 'name', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Name', - help: 'Renaming categories might cause problems with repeating searches from the history.', - required: true - } - }, - { - 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 how indexers will be searched and if autocompletion is available in the GUI" - } - }, - { - key: 'subtype', - type: 'horizontalSelect', - templateOptions: { - label: 'Sub type', - options: [ - {name: 'Anime', value: 'ANIME'}, - {name: 'Audiobook', value: 'AUDIOBOOK'}, - {name: 'Comic', value: 'COMIC'}, - {name: 'Ebook', value: 'EBOOK'}, - {name: 'None', value: 'NONE'} - ], - help: "Special search type. Used for indexer specific mappings between categories and newznab IDs" - } - }, - { - key: 'applyRestrictionsType', - type: 'horizontalSelect', - templateOptions: { - label: 'Apply restrictions', - options: [ - {name: 'All searches', value: 'BOTH'}, - {name: 'Internal searches', value: 'INTERNAL'}, - {name: 'API searches', value: 'API'}, - {name: 'Never', value: 'NONE'} - ], - help: "For which type of search word restrictions will be applied" - } - }, - { - key: 'requiredWords', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Required words', - help: "Must *all* be present in a title which is converted to lowercase before. Apply words with return key." - } - }, - { - key: 'requiredRegex', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Required regex', - help: 'Must be present in a title (case is ignored).' - } - }, - { - key: 'forbiddenWords', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Forbidden words', - help: "None may be present in a title which is converted to lowercase before. Apply words with return key." - } - }, - { - key: 'forbiddenRegex', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Forbidden regex', - help: 'Must not be present in a title (case is ignored).' - } - }, - { - wrapper: 'settingWrapper', - templateOptions: { - label: 'Size preset', - help: "Will set these values on the search page" - }, - fieldGroup: [ - { - key: 'minSizePreset', - type: 'duoSetting', - templateOptions: { - addonRight: { - text: 'MB' - } - - } - }, - { - type: 'duolabel' - }, - { - key: 'maxSizePreset', - type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}} - } - ] - }, - { - key: 'applySizeLimitsToApi', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Limit API results size', - help: "Enable to apply the size preset to API results from this category" - } - }, - { - key: 'newznabCategories', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Newznab categories', - help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.', - tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of "Movies HD" the settings for that category are used. Otherwise it checks if it matches the "Movies" category and, if yes, uses that one. If that one doesn\'t match no category settings are used.

' + - 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using "&" to require multiple numbers to be present in a result. For example "2010&11000" would require a search result to contain both 2010 and 11000 for that category to match.

' + - 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.' - } - }, - { - key: 'ignoreResultsFrom', - type: 'horizontalSelect', - templateOptions: { - label: 'Ignore results', - options: [ - {name: 'For all searches', value: 'BOTH'}, - {name: 'For internal searches', value: 'INTERNAL'}, - {name: 'For API searches', value: 'API'}, - {name: 'Never', value: 'NONE'} - ], - help: "Ignore results from this category", - tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select "Internal" or "Always" this category will also not be selectable on the search page.' - } - } - - ], - defaultModel: { - name: null, - applySizeLimitsToApi: false, - applyRestrictionsType: "NONE", - forbiddenRegex: null, - forbiddenWords: [], - ignoreResultsFrom: "NONE", - mayBeSelected: true, - maxSizePreset: null, - minSizePreset: null, - newznabCategories: [], - preselect: true, - requiredRegex: null, - requiredWords: [], - searchType: "SEARCH", - subtype: "NONE" - } - } - } - ], - downloading: [ - { - wrapper: 'fieldset', - templateOptions: { - label: 'General', - tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.' - }, - fieldGroup: [ - { - key: 'saveTorrentsTo', - type: 'fileInput', - templateOptions: { - label: 'Torrent black hole', - help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.', - type: "folder" - } - }, - { - key: 'saveNzbsTo', - type: 'fileInput', - templateOptions: { - label: 'NZB black hole', - help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.', - type: "folder" - } - }, - { - key: 'nzbAccessType', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'NZB access type', - options: [ - {name: 'Proxy NZBs from indexer', value: 'PROXY'}, - {name: 'Redirect to the indexer', value: 'REDIRECT'} - ], - help: "How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..", - tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).', - advanced: true - - } - }, - { - key: 'externalUrl', - type: 'horizontalInput', - hideExpression: function ($viewValue, $modelValue, scope) { - return !_.any(scope.model.downloaders, function (downloader) { - return downloader.nzbAddingType === "SEND_LINK"; - }); - }, - templateOptions: { - label: 'External URL', - help: 'Used for links when sending links to the downloader.', - tooltip: 'When using "Add links" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\'s not accessible by the downloader (e.g. when it\'s inside a docker container). Set the URL for NZBHydra that\'s accessible by the downloader here and it will be used instead. ', - advanced: true - } - }, - - { - key: 'fallbackForFailed', - type: 'horizontalSelect', - hideExpression: 'model.nzbAccessType === "REDIRECT"', - templateOptions: { - label: 'Fallback for failed downloads', - options: [ - {name: 'GUI downloads', value: 'INTERNAL'}, - {name: 'API downloads', value: 'API'}, - {name: 'All downloads', value: 'BOTH'}, - {name: 'Never', value: 'NONE'} - ], - help: "Fallback to similar results when a download fails. Only available when proxying NZBs (see above).", - tooltip: "When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search." - } - }, - { - key: 'sendMagnetLinks', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Send magnet links', - help: "Enable to send magnet links to the associated program on the server machine. Won't work with docker" - } - }, - { - key: 'updateStatuses', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Update statuses', - help: "Query your downloader for status updates of downloads", - advanced: true - } - }, - { - key: 'showDownloaderStatus', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Show downloader footer', - help: "Show footer with downloader status", - advanced: true - } - }, - { - key: 'primaryDownloader', - type: 'horizontalSelect', - hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus', - templateOptions: { - label: 'Primary downloader', - options: [], - help: "This downloader's state will be shown in the footer.", - tooltip: "To select a downloader you just added please save the config first.", - optionsFunction: function (model) { - var downloaders = []; - _.each(model.downloaders, function (downloader) { - downloaders.push({name: downloader.name, value: downloader.name}) - }) - return downloaders; - }, - optionsFunctionAfter: function (model) { - if (!model.primaryDownloader) { - model.primaryDownloader = model.downloaders[0].name; - } - } - } - }, - ] - }, - { - wrapper: 'fieldset', - key: 'downloaders', - templateOptions: {label: 'Downloaders'}, - fieldGroup: [ - { - type: "downloaderConfig", - data: {} - } - ] - } - ], - - indexers: [ - { - type: "indexers", - data: {} - }, - { - type: 'recheckAllCaps' - } - ], - auth: [ - { - wrapper: 'fieldset', - templateOptions: { - label: 'Main', - - }, - fieldGroup: [ - { - key: 'authType', - type: 'horizontalSelect', - templateOptions: { - label: 'Auth type', - options: [ - {name: 'None', value: 'NONE'}, - {name: 'HTTP Basic auth', value: 'BASIC'}, - {name: 'Login form', value: 'FORM'} - ], - tooltip: '
    ' + - '
  • With auth type "None" all areas are unrestricted.
  • ' + - '
  • With auth type "Form" the basic page is loaded and login is done via a form.
  • ' + - '
  • With auth type "Basic" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
  • ' + - '
' - } - }, - { - key: 'authHeader', - type: 'horizontalInput', - templateOptions: { - type: 'string', - label: 'Auth header', - help: 'Name of header that provides the username in requests from secure sources.', - advanced: true - }, - hideExpression: function () { - return rootModel.auth.authType === "NONE"; - } - }, - { - key: 'authHeaderIpRanges', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Secure IP ranges', - help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like "192.168.0.1-192.168.0.100" or single IP addresses like "127.0.0.1".', - advanced: true - }, - hideExpression: function () { - return rootModel.auth.authType === "NONE" || _.isNullOrEmpty(rootModel.auth.authHeader); - } - }, - { - key: 'rememberUsers', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Remember users', - help: 'Remember users with cookie for 14 days.' - }, - hideExpression: function () { - return rootModel.auth.authType === "NONE"; - } - }, - { - key: 'rememberMeValidityDays', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Cookie expiry', - help: 'How long users are remembered.', - addonRight: { - text: 'days' - }, - advanced: true - } - } - ] - }, + function controller($scope) { + $scope.selected = {value: $scope.options[$scope.preselect].value}; + $scope.active = false; - { - wrapper: 'fieldset', - templateOptions: { - label: 'Restrictions', - tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\'t to allow anonymous users to do anything just leave everything selected.
You can decide for every user if he is allowed to:
' + - '
    \n' + - '
  • view the search page at all
  • \n' + - '
  • view the stats
  • \n' + - '
  • access the admin area (config and control)
  • \n' + - '
  • view links for downloading NZBs and see their details
  • \n' + - '
  • may select which indexers are used for search.
  • \n' + - '
' - }, - hideExpression: function () { - return rootModel.auth.authType === "NONE"; - }, - fieldGroup: [ - { - key: 'restrictSearch', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Restrict searching', - help: 'Restrict access to searching.' - } - }, - { - key: 'restrictStats', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Restrict stats', - help: 'Restrict access to stats.' - } - }, - { - key: 'restrictAdmin', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Restrict admin', - help: 'Restrict access to admin functions.' - } - }, - { - key: 'restrictDetailsDl', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Restrict NZB details & DL', - help: 'Restrict NZB details, comments and download links.' - } - }, - { - key: 'restrictIndexerSelection', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Restrict indexer selection box', - help: 'Restrict visibility of indexer selection box in search. Affects only GUI.' - } - }, - { - key: 'allowApiStats', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Allow stats access', - help: 'Allow access to stats via external API.' - } - } - ] - }, + $scope.apply = function () { + $scope.active = $scope.selected.value !== $scope.options[0].value; + $scope.$emit("filter", $scope.column, { + filterValue: $scope.selected.value, + filterType: "boolean" + }, $scope.active) + }; + $scope.clear = function () { + $scope.selected.value = true; + $scope.active = false; + $scope.$emit("filter", $scope.column, {filterValue: undefined, filterType: "boolean"}, $scope.active) + }; + $scope.$on("clear", $scope.clear); + DebugService.log("filter-boolean"); + } +} - { - type: 'repeatSection', - key: 'users', - model: rootModel.auth, - hideExpression: function () { - return rootModel.auth.authType === "NONE"; - }, - templateOptions: { - btnText: 'Add new user', - altLegendText: 'Authless', - headline: 'Users', - fields: [ - { - key: 'username', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Username', - required: true - } - }, - { - key: 'password', - type: 'passwordSwitch', - templateOptions: { - type: 'password', - label: 'Password', - required: true - } - }, - { - key: 'maySeeAdmin', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'May see admin area' - } - }, - { - key: 'maySeeStats', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'May see stats' - }, - hideExpression: 'model.maySeeAdmin' - }, - { - key: 'maySeeDetailsDl', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'May see NZB details & DL links' - }, - hideExpression: 'model.maySeeAdmin' - }, - { - key: 'showIndexerSelection', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'May see indexer selection box' - }, - hideExpression: 'model.maySeeAdmin' - } - ], - defaultModel: { - username: null, - password: null, - token: null, - maySeeStats: true, - maySeeAdmin: true, - maySeeDetailsDl: true, - showIndexerSelection: true - } - } - } - ], - notificationConfig: [ - { - type: 'help', - templateOptions: { - type: 'help', - lines: [ - "NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.", - 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.', - "NZBHydra will also show notifications on the GUI if enabled." - ] - } - }, - { - wrapper: 'fieldset', - templateOptions: { - label: 'Main' - }, - fieldGroup: [ +angular + .module('nzbhydraApp').directive("timeFilter", timeFilter); - { - key: 'appriseType', - type: 'horizontalSelect', - templateOptions: { - type: 'select', - label: 'Apprise type', - options: [ - {name: 'None', value: 'NONE'}, - {name: 'API', value: 'API'}, - {name: 'CLI', value: 'CLI'} - ] - } - }, - { - key: 'appriseApiUrl', - type: 'horizontalInput', - templateOptions: { - type: 'string', - label: 'Apprise API URL', - help: 'URL of Apprise API to send notifications to.' - }, - hideExpression: 'model.appriseType !== "API"' - }, - { - key: 'appriseCliPath', - type: 'fileInput', - templateOptions: { - type: 'file', - label: 'Apprise runnable', - help: 'Full path of of Apprise runnable to execute.' - }, - hideExpression: 'model.appriseType !== "CLI"' - }, - { - key: 'displayNotifications', - type: 'horizontalSwitch', - templateOptions: { - type: 'switch', - label: 'Display notifications', - help: 'If enabled notifications will be shown on the GUI.' - } - }, - { - key: 'displayNotificationsMax', - type: 'horizontalInput', - templateOptions: { - type: 'number', - label: 'Show max notifications', - help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.' - }, - hideExpression: '!model.displayNotifications' - }, - { - key: 'filterOuts', - type: 'horizontalChips', - templateOptions: { - type: 'text', - label: 'Hide if message contains...', - help: 'Apply values with return key. Surround with "/" for regex (e.g. /contains[0-9]This/). Case insensitive.', +function timeFilter() { + controller.$inject = ["$scope", "DebugService"]; + return { + template: '', + scope: { + column: "@", + selected: "<" + }, + controller: controller + }; + + function controller($scope, DebugService) { - }, - hideExpression: '!model.displayNotifications' - } - ] - }, + $scope.dateOptions = { + dateDisabled: false, + formatYear: 'yy', + startingDay: 1 + }; - { - type: 'notificationSection', - key: 'entries', - model: rootModel.notificationConfig, - templateOptions: { - btnText: 'Add new notification', - altLegendText: 'Notification', - headline: 'Notifications', - fields: [ - { - key: 'appriseUrls', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'URLs', - help: 'One or more URLs identifying where the notification should be sent to, comma-separated.' - } - }, - { - key: 'titleTemplate', - type: 'horizontalInput', - templateOptions: { - type: 'text', - label: 'Title template' - }, - controller: notificationTemplateHelpController - }, - { - key: 'bodyTemplate', - type: 'horizontalTextArea', - templateOptions: { - type: 'text', - label: 'Body template', - required: true - }, - controller: notificationTemplateHelpController - }, - { - key: 'messageType', - type: 'horizontalSelect', - templateOptions: { - label: 'Message type', - options: [ - {name: 'Info', value: 'INFO'}, - {name: 'Success', value: 'SUCCESS'}, - {name: 'Warning', value: 'WARNING'}, - {name: 'Failure', value: 'FAILURE'} - ], - help: "Select the message type to use." - } - }, - { - key: 'bodyTemplate', - type: 'horizontalTestNotification' - } + $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate']; + $scope.format = $scope.formats[0]; + $scope.altInputFormats = ['M!/d!/yyyy']; + $scope.active = false; - ], - defaultModel: { - eventType: null, - appriseUrls: null, - titleTemplate: null, - bodyTemplate: null, - messageType: 'WARNING' - } - } - } - ] + $scope.openAfter = function () { + $scope.after.opened = true; + }; - } + $scope.openBefore = function () { + $scope.before.opened = true; + }; - function notificationTemplateHelpController($scope, NotificationService) { - $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType); - $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType); - } + $scope.after = { + opened: false + }; + + $scope.before = { + opened: false + }; + + $scope.apply = function () { + $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate; + $scope.$emit("filter", $scope.column, { + filterValue: { + after: $scope.selected.afterDate, + before: $scope.selected.beforeDate + }, filterType: "time" + }, $scope.active) + }; + $scope.clear = function () { + $scope.selected.beforeDate = undefined; + $scope.selected.afterDate = undefined; + $scope.active = false; + $scope.$emit("filter", $scope.column, {filterValue: undefined, filterType: "time"}, $scope.active) + }; + $scope.$on("clear", $scope.clear); + DebugService.log("filter-time"); } } -function handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) { - var message; - var yesText; - if (data.checked) { - message = "The connection to the " + whatFailed + " failed: " + data.message + "
Do you want to add it anyway?"; - yesText = "I know what I'm doing"; - } else { - message = "The connection to the " + whatFailed + " could not be tested, sorry. Please check the log."; - yesText = "I'll risk it"; - } - ModalService.open("Connection check failed", message, { - yes: { - onYes: function () { - deferred.resolve(); - }, - text: yesText - }, - no: { - onNo: function () { - model.enabled = false; - deferred.resolve(); - }, - text: "Add it, but disabled" +angular + .module('nzbhydraApp').directive("numberRangeFilter", numberRangeFilter); + +function numberRangeFilter() { + controller.$inject = ["$scope", "DebugService"]; + return { + template: '', + scope: { + column: "@", + min: "<", + max: "<", + addon: "@", + tooltip: "@" }, - cancel: { - onCancel: function () { - deferred.reject(); - }, - text: "Aahh, let me try again" + controller: controller + }; + + function controller($scope, DebugService) { + $scope.filterValue = {min: undefined, max: undefined}; + $scope.active = false; + + function apply() { + $scope.active = $scope.filterValue.min || $scope.filterValue.max; + $scope.$emit("filter", $scope.column, { + filterValue: $scope.filterValue, + filterType: "numberRange" + }, $scope.active) } - }); -} + $scope.clear = function () { + $scope.filterValue = {min: undefined, max: undefined}; + $scope.active = false; + $scope.$emit("filter", $scope.column, { + filterValue: undefined, + filterType: "numberRange", + isBoolean: $scope.isBoolean + }, $scope.active) + }; + $scope.$on("clear", $scope.clear); + + $scope.apply = function () { + apply(); + }; + + $scope.onKeypress = function (keyEvent) { + if (keyEvent.which === 13) { + apply(); + } + }; + + DebugService.log("filter-number"); + } +} -ConfigController.$inject = ["$scope", "$http", "activeTab", "ConfigService", "config", "DownloaderCategoriesService", "ConfigFields", "ConfigModel", "ModalService", "RestartService", "localStorageService", "$state", "growl", "$window"];angular - .module('nzbhydraApp') - .factory('ConfigModel', function () { - return {}; - }); angular - .module('nzbhydraApp') - .factory('ConfigWatcher', function () { - var $scope; + .module('nzbhydraApp').directive("columnSortable", columnSortable); - return { - watch: watch +function columnSortable() { + controller.$inject = ["$scope"]; + return { + restrict: "E", + templateUrl: "static/html/dataTable/columnSortable.html", + transclude: true, + scope: { + sortMode: "<", //0: no sorting, 1: asc, 2: desc + column: "@", + reversed: "<", + startMode: "<" + }, + controller: controller + }; + + function controller($scope) { + if (angular.isUndefined($scope.sortMode)) { + $scope.sortMode = 0; + } + + if (angular.isUndefined($scope.startMode)) { + $scope.startMode = 1; + } + + $scope.sortModel = { + sortMode: $scope.sortMode, + column: $scope.column, + reversed: $scope.reversed, + startMode: $scope.startMode, + active: false + }; + + $scope.$on("newSortColumn", function (event, column, sortMode) { + $scope.sortModel.active = column === $scope.sortModel.column; + if (column !== $scope.sortModel.column) { + $scope.sortModel.sortMode = 0; + } else { + $scope.sortModel.sortMode = sortMode; + } + }); + + $scope.sort = function () { + if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) { + $scope.sortModel.sortMode = $scope.sortModel.startMode; + } else if ($scope.sortModel.sortMode === 1) { + $scope.sortModel.sortMode = 2; + } else { + $scope.sortModel.sortMode = 1; + } + $scope.$emit("sort", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed) }; - function watch(scope) { - $scope = scope; - $scope.$watchGroup(["config.main.host"], function () { - }, true); - } - }); - + } +} angular .module('nzbhydraApp') - .controller('ConfigController', ConfigController); + .directive('connectionTest', connectionTest); -function ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) { - $scope.config = config; - $scope.submit = submit; - $scope.activeTab = activeTab; +function connectionTest() { + controller.$inject = ["$scope"]; + return { + templateUrl: 'static/html/directives/connection-test.html', + require: ['^type', '^data'], + scope: { + type: "=", + id: "=", + data: "=", + downloader: "=" + }, + controller: controller + }; - $scope.restartRequired = false; - $scope.ignoreSaveNeeded = false; - console.log(localStorageService.get("showAdvanced")); - if (localStorageService.get("showAdvanced") === null) { - $scope.showAdvanced = false; - localStorageService.set("showAdvanced", false); - } else { - $scope.showAdvanced = localStorageService.get("showAdvanced"); - } + function controller($scope) { + $scope.message = ""; - $scope.toggleShowAdvanced = function () { - $scope.showAdvanced = !$scope.showAdvanced; - var wasDirty = $scope.form.$dirty === true; + var testButton = "#button-test-connection"; + var testMessage = "#message-test-connection"; - $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true; - //Also save in main tab where it will be stored to file - $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true; - $scope.form.$dirty = wasDirty; - localStorageService.set("showAdvanced", $scope.showAdvanced); - } + function showSuccess() { + angular.element(testButton).removeClass("btn-default"); + angular.element(testButton).removeClass("btn-danger"); + angular.element(testButton).addClass("btn-success"); + } - function updateAndAskForRestartIfNecessary(responseData) { - if (angular.isUndefined($scope.form)) { - console.error("Unable to determine if a restart is necessary"); - return; + function showError() { + angular.element(testButton).removeClass("btn-default"); + angular.element(testButton).removeClass("btn-success"); + angular.element(testButton).addClass("btn-danger"); } - $scope.form.$setPristine(); - DownloaderCategoriesService.invalidate(); - if ($scope.restartRequired) { - ModalService.open("Restart required", "The changes you have made may require a restart to be effective.
Do you want to restart now?", { - yes: { - onYes: function () { - RestartService.restart(); - } - }, - no: { - onNo: function ($uibModalInstance) { - //Needs to be clicked twice for some reason - $scope.restartRequired = false; - $uibModalInstance.dismiss(); - $uibModalInstance.dismiss(); - $scope.config = responseData.newConfig; - $window.location.reload(); + $scope.testConnection = function () { + angular.element(testButton).addClass("glyphicon-refresh-animate"); + var myInjector = angular.injector(["ng"]); + var $http = myInjector.get("$http"); + var url; + var params; + if ($scope.type === "downloader") { + url = "internalapi/test_downloader"; + params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password}; + if ($scope.downloader === "SABNZBD") { + params.apiKey = $scope.data.apiKey; + params.url = $scope.data.url; + } else { + params.host = $scope.data.host; + params.port = $scope.data.port; + params.ssl = $scope.data.ssl; + } + } else if ($scope.data.type === "newznab") { + url = "internalapi/test_newznab"; + params = {host: $scope.data.host, apiKey: $scope.data.apiKey}; + if (angular.isDefined($scope.data.username)) { + params["username"] = $scope.data.username; + params["password"] = $scope.data.password; + } + } + $http.get(url, {params: params}).then(function (result) { + //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click + if (result.successful) { + angular.element(testMessage).text(""); + showSuccess(); + } else { + angular.element(testMessage).text(result.message); + showError(); } + + }, function () { + angular.element(testMessage).text(result.message); + showError(); } - }); - } else { - $scope.config = responseData.newConfig; - $window.location.reload(); + ).finally(function () { + angular.element(testButton).removeClass("glyphicon-refresh-animate"); + }) } + } +} - function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) { - if (angular.isUndefined(ignoreWarnings)) { - ignoreWarnings = localStorageService.get("ignoreWarnings") !== null ? localStorageService.get("ignoreWarnings") : false; - } - //Communication with server was successful but there might be validation errors and/or warnings - var warningMessages = response.data.warningMessages; - var errorMessages = response.data.errorMessages; - $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false); - var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings); - function extendMessageWithList(message, messages) { - _.forEach(messages, function (x) { - message += "
  • " + x + "
  • "; - }); - message += ""; - return message; - } +//Taken from https://github.com/IamAdamJowett/angular-click-outside - if (showMessage) { - var options; - var message; - var title; - if (errorMessages.length > 0) { //Actual errors which cannot be ignored - title = "Config validation failed"; - message = 'The following errors have been found in your config. They need to be fixed.
      '; - message = extendMessageWithList(message, response.data.errorMessages); - if (warningMessages.length > 0) { - message += '
      The following warnings were found. You can ignore them if you wish.
        '; - message = extendMessageWithList(message, response.data.warningMessages); - } - options = { - yes: { - onYes: function () { - }, - text: "OK" +clickOutside.$inject = ["$document", "$parse", "$timeout"]; +function childOf(/*child node*/c, /*parent node*/p) { //returns boolean + while ((c = c.parentNode) && c !== p) ; + return !!c; +} + +angular + .module('nzbhydraApp').directive("clickOutside", clickOutside); + +/** + * @ngdoc directive + * @name angular-click-outside.directive:clickOutside + * @description Directive to add click outside capabilities to DOM elements + * @requires $document + * @requires $parse + * @requires $timeout + **/ +function clickOutside($document, $parse, $timeout) { + return { + restrict: 'A', + link: function ($scope, elem, attr) { + + // postpone linking to next digest to allow for unique id generation + $timeout(function () { + var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [], + fn; + + function eventHandler(e) { + var i, + element, + r, + id, + classNames, + l; + + // check if our element already hidden and abort if so + if (angular.element(elem).hasClass("ng-hide")) { + return; } - }; - } else if (warningMessages.length > 0) { - title = "Config validation warnings"; - message = '
        The following warnings have been found. You can ignore them if you wish. The config was already saved.
          '; - message = extendMessageWithList(message, response.data.warningMessages); - options = { - // cancel: { - // onCancel: function () { - // $scope.form.$setPristine(); - // localStorageService.set("ignoreWarnings", true); - // ConfigService.set($scope.config, true).then(function (response) { - // handleConfigSetResponse(response, true, $scope.restartRequired); - // updateAndAskForRestartIfNecessary(response.data); - // }, function (response) { - // //Actual error while setting or validating config - // growl.error(response.data); - // }); - // }, - // text: "OK, don't show warnings again" - // }, - yes: { - onYes: function () { - handleConfigSetResponse(response, true, $scope.restartRequired); - updateAndAskForRestartIfNecessary(response.data); - }, - text: "OK" + + // if there is no click target, no point going on + if (!e || !e.target) { + return; } - }; - } - ModalService.open(title, message, options, "md", "left"); - } else { - updateAndAskForRestartIfNecessary(response.data); - } - } - function submit() { - if ($scope.form.$valid && !$scope.myShowError) { - ConfigService.set($scope.config, true).then(function (response) { - handleConfigSetResponse(response); - }, function (response) { - //Actual error while setting or validating config - growl.error(response.data); - }); + if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) { + return; + } + var isChild = childOf(e.target, elem.context); + if (isChild) { + return; + } + // loop through the available elements, looking for classes in the class list that might match and so will eat + for (element = e.target; element; element = element.parentNode) { + // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru) + if (element === elem[0]) { + return; + } + + // now we have done the initial checks, start gathering id's and classes + id = element.id, + classNames = element.className, + l = classList.length; + + // Unwrap SVGAnimatedString classes + if (classNames && classNames.baseVal !== undefined) { + classNames = classNames.baseVal; + } - } else { - growl.error("Config invalid. Please check your settings."); + // if there are no class names on the element clicked, skip the check + if (classNames || id) { - //Ridiculously hacky way to make the error messages appear - try { - if (angular.isDefined(form.$error.required)) { - _.each(form.$error.required, function (item) { - if (angular.isDefined(item.$error.required)) { - _.each(item.$error.required, function (item2) { - item2.$setTouched(); - }); + // loop through the elements id's and classnames looking for exceptions + for (i = 0; i < l; i++) { + //prepare regex for class word matching + r = new RegExp('\\b' + classList[i] + '\\b'); + + // check for exact matches on id's or classes, but only if they exist in the first place + if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) { + // now let's exit out as it is an element that has been defined as being ignored for clicking outside + return; + } + } } + } + + // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute + $timeout(function () { + fn = $parse(attr['clickOutside']); + fn($scope, {event: e}); }); } - angular.forEach($scope.form.$error.required, function (field) { - field.$setTouched(); - }); - } catch (err) { - // - } - } - } + // if the devices has a touchscreen, listen for this event + if (_hasTouch()) { + $document.on('touchstart', eventHandler); + } - ConfigModel = config; + // still listen for the click event even if there is touch to cater for touchscreen laptops + $document.on('click', eventHandler); - $scope.fields = ConfigFields.getFields($scope.config); + // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around + $scope.$on('$destroy', function () { + if (_hasTouch()) { + $document.off('touchstart', eventHandler); + } - $scope.allTabs = [ - { - active: false, - state: 'root.config.main', - name: 'Main', - model: ConfigModel.main, - fields: $scope.fields.main - }, - { - active: false, - state: 'root.config.auth', - name: 'Authorization', - model: ConfigModel.auth, - fields: $scope.fields.auth, - options: {} - }, - { - active: false, - state: 'root.config.searching', - name: 'Searching', - model: ConfigModel.searching, - fields: $scope.fields.searching, - options: {} - }, - { - active: false, - state: 'root.config.categories', - name: 'Categories', - model: ConfigModel.categoriesConfig, - fields: $scope.fields.categoriesConfig, - options: {} - }, - { - active: false, - state: 'root.config.downloading', - name: 'Downloading', - model: ConfigModel.downloading, - fields: $scope.fields.downloading, - options: {} - }, - { - active: false, - state: 'root.config.indexers', - name: 'Indexers', - model: ConfigModel.indexers, - fields: $scope.fields.indexers, - options: {} - }, - { - active: false, - state: 'root.config.notifications', - name: 'Notifications', - model: ConfigModel.notificationConfig, - fields: $scope.fields.notificationConfig, - options: {} + $document.off('click', eventHandler); + }); + + /** + * @description Private function to attempt to figure out if we are on a touch device + * @private + **/ + function _hasTouch() { + // works on most browsers, IE10/11 and Surface + return 'ontouchstart' in window || navigator.maxTouchPoints; + } + }); } - ]; + }; +} - //Copy showAdvanced setting over from main tab's setting - _.each($scope.allTabs, function (tab) { - tab.model.showAdvanced = $scope.showAdvanced === true; - }) +angular + .module('nzbhydraApp') + .directive('cfgFormEntry', cfgFormEntry); - $scope.isSavingNeeded = function () { - return $scope.form.$dirty && $scope.form.$valid && !$scope.ignoreSaveNeeded; +function cfgFormEntry() { + return { + templateUrl: 'static/html/directives/cfg-form-entry.html', + require: ["^title", "^cfg"], + scope: { + title: "@", + cfg: "=", + help: "@", + type: "@?", + options: "=?" + }, + controller: ["$scope", "$element", "$attrs", function ($scope, $element, $attrs) { + $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text'; + $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : []; + }] }; +} +angular + .module('nzbhydraApp') + .directive('hydrabackup', hydrabackup); - $scope.goToConfigState = function (index) { - $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true}); +function hydrabackup() { + controller.$inject = ["$scope", "BackupService", "Upload", "FileDownloadService", "$http", "RequestsErrorHandler", "growl", "RestartService"]; + return { + templateUrl: 'static/html/directives/backup.html', + controller: controller }; - $scope.apiHelp = function () { + function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) { + $scope.refreshBackupList = function () { + BackupService.getBackupsList().then(function (backups) { + $scope.backups = backups; + }); + }; - if ($scope.isSavingNeeded()) { - growl.info("Please save first"); - return; - } - var apiHelp = ConfigService.apiHelp().then(function (data) { + $scope.refreshBackupList(); - var html = '' + - '' + - '' + - '' + - '
          Newznab API endpoint:%newznab%
          Torznab API endpoint:%torznab%
          API key:%apikey%
          '; - //Torznab API endpoint: %torznab%
          API key: %apikey% - html = html.replace("%newznab%", data.newznabApi); - html = html.replace("%torznab%", data.torznabApi); - html = html.replace("%apikey%", data.apiKey); - ModalService.open("API infos", html, {}, "md"); - }); - }; + $scope.uploadActive = false; - $scope.configureIn = function (externalTool) { - if ($scope.isSavingNeeded()) { - growl.info("Please save first"); - return; + $scope.createBackupFile = function () { + $http.get("internalapi/backup/backuponly", {params: {dontdownload: true}}).then(function () { + $scope.refreshBackupList(); + }); + }; + $scope.createAndDownloadBackupFile = function () { + FileDownloadService.downloadFile("internalapi/backup/backup", "nzbhydra-backup-" + moment().format("YYYY-MM-DD-HH-mm") + ".zip", "GET").then(function () { + $scope.refreshBackupList(); + }); + }; + + $scope.uploadBackupFile = function (file, errFiles) { + RequestsErrorHandler.specificallyHandled(function () { + + $scope.file = file; + $scope.errFile = errFiles && errFiles[0]; + if (file) { + $scope.uploadActive = true; + file.upload = Upload.upload({ + url: 'internalapi/backup/restorefile', + file: file + }); + + file.upload.then(function (response) { + if (response.data.successful) { + $scope.uploadActive = false; + RestartService.startCountdown("Upload successful. Restarting for wrapper to restore data."); + } else { + file.progress = 0; + growl.error(response.data.message) + } + + }, function (response) { + growl.error(response.data.message) + }, function (evt) { + file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total)); + file.loaded = Math.floor(evt.loaded / 1024); + file.total = Math.floor(evt.total / 1024); + }); + } + }); + }; + + $scope.restoreFromFile = function (filename) { + BackupService.restoreFromFile(filename).then(function () { + RestartService.startCountdown("Extraction of backup successful. Restarting for wrapper to restore data."); + }, + function (response) { + growl.error(response.data); + }) } - ConfigService.configureIn(externalTool); + + } +} + + + +addableNzbs.$inject = ["DebugService"];angular + .module('nzbhydraApp') + .directive('addableNzbs', addableNzbs); + +function addableNzbs(DebugService) { + controller.$inject = ["$scope", "NzbDownloadService"]; + return { + templateUrl: 'static/html/directives/addable-nzbs.html', + require: [], + scope: { + searchresult: "<", + alwaysAsk: "<" + }, + controller: controller }; - $scope.$on('$stateChangeStart', - function (event, toState, toParams, fromState, fromParams) { - if ($scope.isSavingNeeded()) { - event.preventDefault(); - ModalService.open("Unsaved changed", "Do you want to save before leaving?", { - yes: { - onYes: function () { - $scope.submit(); - $state.go(toState); - }, - text: "Yes" - }, - no: { - onNo: function () { - $scope.ignoreSaveNeeded = true; - $scope.allTabs[$scope.activeTab].options.resetModel(); - $state.go(toState); - }, - text: "No" - }, - cancel: { - onCancel: function () { - event.preventDefault(); - }, - text: "Cancel" - } - }); + function controller($scope, NzbDownloadService) { + $scope.alwaysAsk = $scope.alwaysAsk === "true"; + $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) { + if ($scope.searchresult.downloadType !== "NZB") { + return downloader.downloadType === $scope.searchresult.downloadType } + return true; }); + } +} - $scope.$watch("$scope.form.$valid", function () { - }); - $scope.$on('$formValidity', function (event, isValid) { - console.log("Received $formValidity event: " + isValid); - $scope.form.$valid = isValid; - $scope.form.$invalid = !isValid; - $scope.showError = !isValid; - $scope.myShowError = !isValid; - }); -} +addableNzb.$inject = ["DebugService"];angular + .module('nzbhydraApp') + .directive('addableNzb', addableNzb); +function addableNzb(DebugService) { + controller.$inject = ["$scope", "NzbDownloadService", "growl"]; + return { + templateUrl: 'static/html/directives/addable-nzb.html', + scope: { + searchresult: "=", + downloader: "<", + alwaysAsk: "<" + }, + controller: controller + }; + function controller($scope, NzbDownloadService, growl) { + if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) { + $scope.cssClass = "fa fa-" + $scope.downloader.iconCssClass.replace("fa-", "").replace("fa ", ""); + } else { + $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd" : "nzbget"; + } + $scope.add = function () { + var originalClass = $scope.cssClass; + $scope.cssClass = "nzb-spinning"; + NzbDownloadService.download($scope.downloader, [{ + searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id, + originalCategory: $scope.searchresult.originalCategory, + mappedCategory: $scope.searchresult.category + }], $scope.alwaysAsk).then(function (response) { + if (response !== "dismissed") { + if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) { + $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-success" : "nzbget-success"; + } else { + $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-error" : "nzbget-error"; + growl.error(response.data.message); + } + } else { + $scope.cssClass = originalClass; + } + }, function () { + $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-error" : "nzbget-error"; + growl.error("An unexpected error occurred while trying to contact NZBHydra or add the NZB."); + }) + }; + } +} /* * (C) Copyright 2017 TheOtherP (theotherp@posteo.net) * @@ -6232,1415 +3577,1714 @@ function ConfigController($scope, $http, activeTab, ConfigService, config, Downl * limitations under the License. */ -angular - .module('nzbhydraApp') - .directive('hydraTasks', hydraTasks); - -function hydraTasks() { - controller.$inject = ["$scope", "$http"]; +CheckCapsModalInstanceCtrl.$inject = ["$scope", "$interval", "$http", "$timeout", "growl", "capsCheckRequest"]; +IndexerConfigBoxService.$inject = ["$http", "$q", "$uibModal"]; +IndexerCheckBeforeCloseService.$inject = ["$q", "ModalService", "IndexerConfigBoxService", "growl", "blockUI"]; +function regexValidator(regex, message, prefixViewValue, preventEmpty) { return { - templateUrl: 'static/html/directives/tasks.html', - controller: controller + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + if (value) { + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i++) { + if (!regex.test(value[i])) { + return false; + } + } + return true; + } else { + return regex.test(value); + } + } + return !preventEmpty; + }, + message: (prefixViewValue ? '$viewValue + " ' : '" ') + message + '"' }; +} - function controller($scope, $http) { - - $http.get("internalapi/tasks").then(function (response) { - $scope.tasks = response.data; +function getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) { + var fieldset = []; + if (indexerModel.searchModuleType === "TORZNAB") { + fieldset.push({ + type: 'help', + templateOptions: { + type: 'help', + lines: ["Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api"] + } }); - - $scope.runTask = function (taskName) { - $http.put("internalapi/tasks/" + taskName).then(function (response) { - $scope.tasks = response.data; - }); - } } -} - - -angular - .module('nzbhydraApp') - .directive('tabOrChart', tabOrChart); - -function tabOrChart() { - return { - templateUrl: 'static/html/directives/tab-or-chart.html', - transclude: { - "chartSlot": "chart", - "tableSlot": "table" - }, - restrict: 'E', - replace: true, - scope: { - display: "@" + if ((indexerModel.searchModuleType === "NEWZNAB" || indexerModel.searchModuleType === "TORZNAB") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { + var message; + var cssClass; + if (!indexerModel.configComplete) { + message = "The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration."; + cssClass = "alert alert-danger"; + } else { + message = "The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable."; + cssClass = "alert alert-warning"; } + fieldset.push({ + type: 'help', + hideExpression: 'model.allCapsChecked && model.configComplete', + templateOptions: { + type: 'help', + lines: [message], + class: cssClass + } + }); + } - }; - -} - -angular - .module('nzbhydraApp') - .directive('selectionButton', selectionButton); - -function selectionButton() { - controller.$inject = ["$scope"]; - return { - templateUrl: 'static/html/directives/selection-button.html', - scope: { - selected: "=", - selectable: "=", - invertSelection: "<", - selectAll: "<", - deselectAll: "<", - btn: "@" - }, - controller: controller - }; - - function controller($scope) { - - if (angular.isUndefined($scope.btn)) { - $scope.btn = "default"; //Will form class "btn-default" + var stateHelp = ""; + if (indexerModel.state === "DISABLED_SYSTEM_TEMPORARY" || indexerModel.state === "DISABLED_SYSTEM") { + if (indexerModel.state === "DISABLED_SYSTEM_TEMPORARY") { + stateHelp = "The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually"; + } else { + stateHelp = "The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens."; } + } - if (angular.isUndefined($scope.invertSelection)) { - $scope.invertSelection = function () { - $scope.selected = _.difference($scope.selectable, $scope.selected); - }; - } + if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') { + fieldset.push( + { + key: 'name', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Name', + required: true + }, + validators: { + uniqueName: { + expression: function (viewValue) { + if (isInitial || viewValue !== indexerModel.name) { + return _.pluck(parentModel, "name").indexOf(viewValue) === -1; + } + return true; + }, + message: '"Indexer \\"" + $viewValue + "\\" already exists"' + }, + noComma: + { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + if (value) { + return value.indexOf(",") === -1; + } + return true; + }, + message: '"Name may not contain a comma"' + } + } + }) + } - if (angular.isUndefined($scope.selectAll)) { - $scope.selectAll = function () { - $scope.selected.push.apply($scope.selected, $scope.selectable); - }; - } + if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') { + fieldset.push({ + key: 'state', + type: 'horizontalIndexerStateSwitch', + templateOptions: { + type: 'switch', + label: 'State', + help: stateHelp + } + }); + } - if (angular.isUndefined($scope.deselectAll)) { - $scope.deselectAll = function () { - $scope.selected.splice(0, $scope.selected.length); - }; + if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) { + var hostField = { + key: 'host', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Host', + required: true, + placeholder: 'http://www.someindexer.com' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + }; + if (indexerModel.searchModuleType === 'TORZNAB') { + hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one'; } - - + fieldset.push( + hostField + ); } -} - - - -NfoModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "nfo"];angular - .module('nzbhydraApp') - .directive('searchResult', searchResult); -function searchResult() { - controller.$inject = ["$scope", "$element", "$http", "growl", "$attrs", "$uibModal", "$window", "DebugService", "localStorageService", "HydraAuthService", "ConfigService"]; - return { - templateUrl: 'static/html/directives/search-result.html', - require: '^result', - replace: false, - scope: { - result: "<", - searchResultsControllerShared: "<" - }, - controller: controller - }; + if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') { + fieldset.push( + { + key: 'apiKey', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'API Key' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + } + ) + } + if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { + fieldset.push( + { + key: 'apiPath', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'API path', + help: 'Path to the API. If empty /api is used', + required: false, + advanced: true + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + } + ) + } - function handleDisplay($scope, localStorageService, ConfigService) { - //Display state / expansion - $scope.foo.duplicatesDisplayed = localStorageService.get("duplicatesDisplayed") !== null ? localStorageService.get("duplicatesDisplayed") : false; - $scope.foo.showCovers = localStorageService.get("showCovers") !== null ? localStorageService.get("showCovers") : true; - $scope.foo.alwaysShowTitles = localStorageService.get("alwaysShowTitles") !== null ? localStorageService.get("alwaysShowTitles") : true; - $scope.duplicatesExpanded = false; - $scope.titlesExpanded = $scope.searchResultsControllerShared.expandGroupsByDefault; - $scope.coverSize = ConfigService.getSafe().searching.coverSize; + if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) { + fieldset.push( + { + key: 'username', + type: 'horizontalInput', + templateOptions: { + type: 'text', + required: false, + label: 'Username', + help: 'Only needed if indexer requires HTTP auth for API access (rare).' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + } + ); + } - function calculateDisplayState() { - $scope.resultDisplayed = ($scope.result.titleGroupIndex === 0 || $scope.titlesExpanded) && ($scope.duplicatesExpanded || $scope.result.duplicateGroupIndex === 0); - } + if ('WTFNZB' === indexerModel.searchModuleType) { + fieldset.push( + { + key: 'username', + type: 'horizontalInput', + templateOptions: { + type: 'text', + required: true, + label: 'Username', + help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + } + ); + fieldset.push( + { + key: 'password', + type: 'passwordSwitch', + hideExpression: '!model.username', + templateOptions: { + type: 'text', + required: false, + label: 'Password', + help: 'Only needed if indexer requires HTTP auth for API access (rare).' + } + } + ) + } - calculateDisplayState(); - $scope.toggleTitleExpansion = function () { - $scope.titlesExpanded = !$scope.titlesExpanded; - $scope.$emit("toggleTitleExpansionUp", $scope.titlesExpanded, $scope.result.titleGroupIndicator); - }; + if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') { + fieldset.push( + { + key: 'score', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Priority', + required: true, + help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.', + tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name).
          The result from the indexer with the highest number is shown first in the GUI and returned for API searches.' - $scope.toggleDuplicateExpansion = function () { - $scope.duplicatesExpanded = !$scope.duplicatesExpanded; - $scope.$emit("toggleDuplicateExpansionUp", $scope.duplicatesExpanded, $scope.result.hash); - }; + } + }); + } - $scope.$on("toggleTitleExpansionDown", function ($event, value, titleGroupIndicator) { - if ($scope.result.titleGroupIndicator === titleGroupIndicator) { - $scope.titlesExpanded = value; - calculateDisplayState(); + fieldset.push( + { + key: 'timeout', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Timeout', + min: 1, + help: 'Supercedes the general timeout in "Searching".', + advanced: true } - }); + }, + { + key: 'schedule', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Schedule', + help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.', + advanced: true + } + } + ); - $scope.$on("toggleDuplicateExpansionDown", function ($event, value, hash) { - if ($scope.result.hash === hash) { - $scope.duplicatesExpanded = value; - calculateDisplayState(); + if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { + fieldset.push( + { + key: 'hitLimit', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'API hit limit', + help: 'Maximum number of API hits since "API hit reset time".', + tooltip: 'When the maximum number of API hits is reached the indexer isn\'t used anymore. Only API hits done by NZBHydra are taken into account.' + }, + validators: { + greaterThanZero: { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + return _.isNullOrEmpty(value) || value > 0; + }, + message: '"Value must be greater than 0"' + } + } + }, + { + key: 'downloadLimit', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Download limit', + help: 'When # of downloads since "Hit reset time" is reached indexer will not be searched.' + }, + validators: { + greaterThanZero: { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + return _.isNullOrEmpty(value) || value > 0; + }, + message: '"Value must be greater than 0"' + } + } } - }); - - $scope.$on("toggleShowCovers", function ($event, value) { - $scope.foo.showCovers = value; - }); - - $scope.$on("toggleAlwaysShowTitles", function ($event, value) { - $scope.foo.alwaysShowTitles = value; - console.log("alwaysShowTitles: " + alwaysShowTitles); - }); - - $scope.$on("duplicatesDisplayed", function ($event, value) { - $scope.foo.duplicatesDisplayed = value; - if (!value) { - //Collapse duplicate groups they shouldn't be displayed - $scope.duplicatesExpanded = false; + ); + fieldset.push( + { + key: 'hitLimitResetTime', + type: 'horizontalInput', + hideExpression: '!model.hitLimit && !model.downloadLimit', + templateOptions: { + type: 'number', + label: 'Hit reset time', + help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.', + tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.' + }, + validators: { + timeOfDay: { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + return value >= 0 && value <= 23; + }, + message: '$viewValue + " is not a valid hour of day (0-23)"' + } + } + }, + { + key: 'loadLimitOnRandom', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Load limiting', + help: 'If set indexer will only be picked for one out of x API searches (on average).', + tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.', + advanced: true + }, + validators: { + greaterThanZero: { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + return _.isNullOrEmpty(value) || value > 1; + }, + message: '"Value must be greater than 1"' + } + } } - calculateDisplayState(); - }); - - $scope.$on("calculateDisplayState", function () { - calculateDisplayState(); - }); + ); } - - function handleSelection($scope, $element) { - $scope.foo.selected = false; - - function sendSelectionEvent(isSelected) { - $scope.$emit("selectionUp", $scope.result, isSelected); - } - - $scope.clickCheckbox = function (event, result) { - var isSelected = event.currentTarget.checked; - sendSelectionEvent(isSelected); - $scope.$emit("checkboxClicked", event, isSelected, event.currentTarget); - }; - - function isBetween(num, betweena, betweenb) { - return (betweena <= num && num <= betweenb) || (betweena >= num && num >= betweenb); - } - - $scope.$on("shiftClick", function (event, newValue, previousClickTargetElement, newClickTargetElement) { - //Parent needs to be the td, between checkbox and td are two divs - var fromYlocation = $(previousClickTargetElement).parent().parent().parent().prop("offsetTop"); - var newYlocation = $(newClickTargetElement).parent().parent().parent().prop("offsetTop"); - var elementYlocation = $($element).prop("offsetTop"); - if (!$scope.resultDisplayed) { - return; + if (indexerModel.searchModuleType === 'TORZNAB') { + fieldset.push({ + key: 'minSeeders', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Minimum # seeders', + help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.' } + }) + } - if (isBetween(elementYlocation, fromYlocation, newYlocation)) { - sendSelectionEvent(newValue); - $scope.foo.selected = newValue === 1; + if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) { + fieldset.push( + { + key: 'userAgent', + type: 'horizontalInput', + templateOptions: { + type: 'text', + required: false, + label: 'User agent', + help: 'Rarely needed. Will supercede the one in the main searching settings.', + advanced: true + } } - }); + ) + } - $scope.$on("invertSelection", function () { - if (!$scope.resultDisplayed) { - return; + if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) { + fieldset.push( + { + key: 'customParameters', + type: 'horizontalChips', + templateOptions: { + 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.', + advanced: 'true' + } } - $scope.foo.selected = !$scope.foo.selected; - sendSelectionEvent($scope.foo.selected); - }); + ) + } - $scope.$on("deselectAll", function () { - if (!$scope.resultDisplayed) { - return; + fieldset.push( + { + key: 'preselect', + type: 'horizontalSwitch', + hideExpression: 'model.enabledForSearchSource==="EXTERNAL"', + templateOptions: { + type: 'switch', + label: 'Preselect', + help: 'Preselect this indexer on the search page.' } - $scope.foo.selected = false; - sendSelectionEvent($scope.foo.selected); - }); - - $scope.$on("selectAll", function () { - if (!$scope.resultDisplayed) { - return; + } + ); + fieldset.push( + { + key: 'enabledForSearchSource', + type: 'horizontalSelect', + templateOptions: { + label: 'Enable for...', + options: [ + {name: 'Internal searches only', value: 'INTERNAL'}, + {name: 'API searches only', value: 'API'}, + {name: 'All but API update queries ', value: 'ALL_BUT_RSS'}, + {name: 'Only API update queries ', value: 'ONLY_RSS'}, + {name: 'Internal and any API searches', value: 'BOTH'} + ], + help: 'Select for which searches this indexer will be used. "Update queries" are searches without query or ID (e.g. done by Sonarr periodically).', + advanced: true } - $scope.foo.selected = true; - - sendSelectionEvent($scope.foo.selected); - }); + } + ); - $scope.$on("toggleSelection", function ($event, result, value) { - if (!$scope.resultDisplayed || result !== $scope.result) { - return; + fieldset.push( + { + key: 'color', + type: 'colorInput', + templateOptions: { + label: 'Color', + help: 'If set it will be used in the search results to mark the indexer\'s results.', + tooltip: 'To mark expanded results they\'re shown in a darker shade so it\'s recommended to use indexer colors which not only differ in lightness', + advanced: true } - $scope.foo.selected = value; - }); - } - - function handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService) { - $scope.showDetailsDl = HydraAuthService.getUserInfos().maySeeDetailsDl; - - $scope.showNfo = showNfo; + } + ); - function showNfo(resultItem) { - if (resultItem.has_nfo === 0) { - return; + fieldset.push( + { + key: 'vipExpirationDate', + type: 'horizontalInput', + templateOptions: { + required: false, + label: 'VIP expiry', + help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or "Lifetime".' + }, + validators: { + port: regexValidator(/^(\d{4}-\d{2}-\d{2})|Lifetime$/, "is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')", true, false) } - var uri = new URI("internalapi/nfo/" + resultItem.searchResultId); - return $http.get(uri.toString()).then(function (response) { - if (response.data.successful) { - if (response.data.hasNfo) { - $scope.openModal("lg", response.data.content) - } else { - growl.info("No NFO available"); - } - } else { - growl.error(response.data.content); - } - }); } + ); - $scope.openModal = openModal; - - function openModal(size, nfo) { - var modalInstance = $uibModal.open({ - template: '
          ', - controller: NfoModalInstanceCtrl, - size: size, - resolve: { - nfo: function () { - return nfo; - } + if (indexerModel.searchModuleType !== "ANIZB" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { + var cats = CategoriesService.getWithoutAll(); + var options = _.map(cats, function (x) { + return {id: x.name, label: x.name} + }); + fieldset.push( + { + key: 'enabledCategories', + type: 'horizontalMultiselect', + templateOptions: { + label: 'Categories', + help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.', + options: options, + settings: { + showSelectedValues: false, + noSelectedText: "None/All" + }, + advanced: true } - }); - - modalInstance.result.then(); - } - - $scope.getNfoTooltip = function () { - if ($scope.result.hasNfo === "YES") { - return "Show NFO" - } else if ($scope.result.hasNfo === "MAYBE") { - return "Try to load NFO (may not be available)"; - } else { - return "No NFO available"; } - }; + ); } - function handleNzbDownload($scope, $window) { - $scope.downloadNzb = downloadNzb; - function downloadNzb(resultItem) { - //href = "{{ result.link }}" - $window.location.href = resultItem.link; - } + if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') { + fieldset.push( + { + key: 'supportedSearchIds', + type: 'horizontalMultiselect', + templateOptions: { + label: 'Search IDs', + options: [ + {label: 'IMDB (TV)', id: 'TVIMDB'}, + {label: 'TVDB', id: 'TVDB'}, + {label: 'TVRage', id: 'TVRAGE'}, + {label: 'Trakt', id: 'TRAKT'}, + {label: 'TVMaze', id: 'TVMAZE'}, + {label: 'IMDB', id: 'IMDB'}, + {label: 'TMDB', id: 'TMDB'} + ], + noSelectedText: "None", + advanced: true + } + } + ); + fieldset.push( + { + key: 'supportedSearchTypes', + type: 'horizontalMultiselect', + templateOptions: { + label: 'Search types', + options: [ + {label: 'Audio', id: 'AUDIO'}, + {label: 'Ebooks', id: 'BOOK'}, + {label: 'Movies', id: 'MOVIE'}, + {label: 'Search', id: 'SEARCH'}, + {label: 'TV', id: 'TVSEARCH'} + ], + buttonText: "None", + advanced: true + } + } + ); + fieldset.push( + { + type: 'horizontalCheckCaps', + hideExpression: '!model.host || !model.name', + templateOptions: { + label: 'Check capabilities', + help: 'Find out what search types and IDs the indexer supports.', + tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.' + } + } + ) } - - function controller($scope, $element, $http, growl, $attrs, $uibModal, $window, DebugService, localStorageService, HydraAuthService, ConfigService) { - $scope.foo = {}; - handleDisplay($scope, localStorageService, ConfigService); - handleSelection($scope, $element); - handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService); - handleNzbDownload($scope, $window); - - $scope.kify = function () { - return function (number) { - if (number > 1000) { - return Math.round(number / 1000) + "k"; + if (indexerModel.searchModuleType === 'NZBINDEX') { + fieldset.push( + { + key: 'generalMinSize', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Min size', + help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category' } - return number; - }; - }; - - - $scope.showCover = function (url) { - console.log("Show " + url); - $uibModal.open({ - template: '', - controller: ["$scope", "url", function ($scope, url) { - $scope.url = url; - }], - resolve: { - url: function () { - return url; - } - }, - size: "md", - keyboard: true, - windowTopClass: 'cover-modal-dialog' - }); - }; - + } + ); } -} - -angular - .module('nzbhydraApp') - .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl); -function NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) { - - $scope.nfo = nfo; - - $scope.ok = function () { - $uibModalInstance.close($scope.selected.item); - }; + if (indexerModel.searchModuleType === 'BINSEARCH') { + fieldset.push({ + key: 'binsearchOtherGroups', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Search in other groups', + help: 'If disabled binsearch will only search in the most popular usenet groups' + } + }) + } - $scope.cancel = function () { - $uibModalInstance.dismiss(); - }; + return fieldset; } -angular - .module('nzbhydraApp') - .filter('kify', function () { - return function (number) { - if (number > 1000) { - return Math.round(number / 1000) + "k"; +function _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) { + var modalInstance = $uibModal.open({ + templateUrl: 'static/html/config/indexer-config-box.html', + controller: 'IndexerConfigBoxInstanceController', + size: 'lg', + resolve: { + model: function () { + indexerModel.showAdvanced = parentModel.showAdvanced; + return indexerModel; + }, + fields: function () { + return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode); + }, + form: function () { + return form; + }, + isInitial: function () { + return isInitial + }, + parentModel: function () { + return parentModel; + } + , + info: function () { + return indexerModel.info; } - return number; } }); -angular - .module('nzbhydraApp') - .directive('saveOrSendFile', saveOrSendFile); - -function saveOrSendFile() { - controller.$inject = ["$scope", "$http", "growl", "ConfigService"]; - return { - templateUrl: 'static/html/directives/save-or-send-file.html', - scope: { - searchResultId: "<", - isFile: "<", - type: "<" - }, - controller: controller - }; - - function controller($scope, $http, growl, ConfigService) { - $scope.cssClass = "glyphicon-save-file"; - var endpoint; - if ($scope.type === "TORRENT") { - $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks; - $scope.tooltip = "Save torrent to black hole or send magnet link"; - endpoint = "internalapi/saveOrSendTorrent"; - } else { - $scope.tooltip = "Save NZB to black hole"; - $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo); - endpoint = "internalapi/saveNzbToBlackhole"; - } - $scope.add = function () { - $scope.cssClass = "nzb-spinning"; - $http.put(endpoint, $scope.searchResultId).then(function (response) { - if (response.data.successful) { - $scope.cssClass = "glyphicon-ok"; - } else { - $scope.cssClass = "glyphicon-remove"; - growl.error(response.data.message); - } - }); - }; - } -} - -//Can be used in an ng-repeat directive to call a function when the last element was rendered -//We use it to mark the end of sorting / filtering so we can stop blocking the UI - -onFinishRender.$inject = ["$timeout"]; -angular - .module('nzbhydraApp') - .directive('onFinishRender', onFinishRender); - -function onFinishRender($timeout) { - function linkFunction(scope, element, attr) { - if (scope.$last === true) { - console.log("Render finished"); - // console.timeEnd("Presenting"); - // console.timeEnd("searchall"); - scope.$emit("onFinishRender") + modalInstance.result.then(function (returnedModel) { + form.$setDirty(true); + if (angular.isDefined(callback)) { + callback(true, returnedModel); } - } - - return { - link: linkFunction - } + }, function () { + if (angular.isDefined(callback)) { + callback(false); + } + }); } -//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly + angular .module('nzbhydraApp') - .directive('multiselectDropdown', - - dropdownMultiselectDirective - ); - -function dropdownMultiselectDirective() { - return { - scope: { - selectedModel: '=', - options: '=', - settings: '=?', - events: '=?' - }, - transclude: { - toggleDropdown: '?toggleDropdown' - }, - templateUrl: 'static/html/directives/multiselect-dropdown.html', - controller: ["$scope", "$element", "$filter", "$document", function dropdownMultiselectController($scope, $element, $filter, $document) { - var $dropdownTrigger = $element.children()[0]; + .config(["formlyConfigProvider", function config(formlyConfigProvider) { - var settings = { - showSelectedValues: true, - showSelectAll: true, - showDeselectAll: true, - noSelectedText: 'None selected' - }; - var events = { - onToggleItem: angular.noop - }; - angular.extend(events, $scope.events || []); - angular.extend(settings, $scope.settings || []); - angular.extend($scope, {settings: settings, events: events}); + formlyConfigProvider.setType({ + name: 'indexers', + templateUrl: 'static/html/config/indexer-config.html', + controller: function ($scope, $uibModal, growl, CategoriesService) { + $scope.showBox = showBox; + $scope.formOptions = {formState: $scope.formState}; + $scope.showPresetSelection = showPresetSelection; - $scope.buttonText = ""; - if (settings.buttonText) { - $scope.buttonText = settings.buttonText; - } else { - $scope.$watch("selectedModel", function () { - if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) { - if ($scope.selectedModel.length === 0) { - if ($scope.settings.noSelectedText) { - $scope.buttonText = $scope.settings.noSelectedText; - } else { - $scope.buttonText = "None selected"; + function showPresetSelection() { + $uibModal.open({ + templateUrl: 'static/html/config/indexer-config-selection.html', + controller: 'IndexerConfigSelectionBoxInstanceController', + size: 'lg', + resolve: { + model: function () { + return $scope.model; + }, + form: function () { + return $scope.form; } - } else if ($scope.selectedModel.length === $scope.options.length) { - $scope.buttonText = "All selected"; - } else { - var selected = []; - _.each($scope.options, function (x) { - if ($scope.selectedModel.indexOf(x.id) > -1) { - selected.push(x.label); - } - }) - $scope.buttonText = selected.join(", "); - } - } else { - if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) { - $scope.buttonText = $scope.settings.noSelectedText; - } else { - $scope.buttonText = $scope.selectedModel.length + " / " + $scope.options.length + " selected"; } - } - }, true); - } - $scope.open = false; - - $scope.toggleDropdown = function () { - $scope.open = !$scope.open; - }; + }); + } - $scope.toggleItem = function (option) { - var index = $scope.selectedModel.indexOf(option.id); - var oldValue = index > -1; - if (oldValue) { - $scope.selectedModel.splice(index, 1); - } else { - $scope.selectedModel.push(option.id); + //Called when clicking the box of an existing indexer + function showBox(indexerModel, model) { + _showBox(indexerModel, model, false, $uibModal, CategoriesService, "indexer", $scope.form) } - $scope.events.onToggleItem(option, !oldValue); - }; - $scope.selectAll = function () { - $scope.selectedModel = _.pluck($scope.options, "id"); - }; + } + }); + }]); - $scope.deselectAll = function () { - $scope.selectedModel.splice(0, $scope.selectedModel.length); - }; - //Close when clicked outside +angular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$uibModal", "$http", "model", "form", "growl", "CategoriesService", "$timeout", "ModalService", "RequestsErrorHandler", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) { - $document.on('click', function (e) { - function contains(collection, target) { - var containsTarget = false; - collection.some(function (object) { - if (object === target) { - containsTarget = true; - return true; - } - return false; - }); - return containsTarget; - } + $scope.showBox = showBox; + $scope.isInitial = false; - if ($scope.open) { - var target = e.target.parentElement; - var parentFound = false; + $scope.select = function (modelPreset) { - while (angular.isDefined(target) && target !== null && !parentFound) { - if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) { - if (target === $dropdownTrigger) { - parentFound = true; - } - } - target = target.parentElement; - } + addEntry(modelPreset); + $timeout(function () { + $uibModalInstance.close(); + }, + 200); + }; - if (!parentFound) { - $scope.$apply(function () { - $scope.open = false; + $scope.readJackettConfig = function () { + var indexerModel = createIndexerModel(); + indexerModel.searchModuleType = "JACKETT_CONFIG"; + indexerModel.isInitial = false; + indexerModel.host = "http://127.0.0.1:9117"; + indexerModel.name = "Jackett config"; + _showBox(indexerModel, model, true, $uibModal, CategoriesService, "jackettConfig", form, function (isSubmitted, returnedModel) { + if (isSubmitted) { + //User pushed button, now we read the config + RequestsErrorHandler.specificallyHandled(function () { + $http.post("internalapi/indexer/readJackettConfig", {existingIndexers: model, jackettConfig: returnedModel}, { + headers: { + "Accept": "application/json;charset=utf-8", + "Accept-Charset": "charset=utf-8" + } + }).then(function (response) { + //Replace model with new result + model.splice(0, model.length); + _.each(response.data.newIndexersConfig, function (x) { + model.push(x); }); - } - } - }); + growl.info("Added " + response.data.addedTrackers + " new trackers from Jackett"); + growl.info("Updated " + response.data.updatedTrackers + " trackers from Jackett"); + }, function (response) { + ModalService.open("Error reading jackett config", response.data, {}, "md", "left"); + }); + }); + } + }); - }] + $timeout(function () { + $uibModalInstance.close(); + }, + 200); + }; + function showBox(indexerModel, model) { + _showBox(indexerModel, model, false, $uibModal, CategoriesService, "indexer", form) } -} -angular - .module('nzbhydraApp').directive("keepFocus", ['$timeout', function ($timeout) { - /* - Intended use: - - */ - return { - restrict: 'A', - require: 'ngModel', - link: function ($scope, $element, attrs, ngModel) { - - ngModel.$parsers.unshift(function (value) { - $timeout(function () { - $element[0].focus(); - }); - return value; - }); - } - }; -}]); -/* - * (C) Copyright 2017 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. - */ + function createIndexerModel() { + return angular.copy({ + allCapsChecked: false, + apiKey: null, + backend: 'NEWZNAB', + color: null, + configComplete: false, + categoryMapping: null, + downloadLimit: null, + enabledCategories: [], + enabledForSearchSource: "BOTH", + generalMinSize: null, + hitLimit: null, + hitLimitResetTime: 0, + host: null, + loadLimitOnRandom: null, + name: null, + password: null, + preselect: true, + score: 0, + searchModuleType: 'NEWZNAB', + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: undefined, + supportedSearchTypes: undefined, + timeout: null, + username: null, + userAgent: null + }); + } -angular - .module('nzbhydraApp') - .directive('indexerStateSwitch', indexerStateSwitch); + function addEntry(preset) { + if (checkAddingAllowed(model, preset)) { + var indexerModel = createIndexerModel(); + if (angular.isDefined(preset)) { + _.extend(indexerModel, preset); + } -function indexerStateSwitch() { - controller.$inject = ["$scope"]; - return { - templateUrl: 'static/html/directives/indexer-state-switch.html', - scope: { - indexer: "=", - handleWidth: "@" - }, - replace: true, - controller: controller - }; + $scope.isInitial = true; - function controller($scope) { - $scope.value = $scope.indexer.state === "ENABLED"; - $scope.handleWidth = $scope.handleWidth || "130px"; - var initialized = false; + _showBox(indexerModel, model, true, $uibModal, CategoriesService, "indexer", form, function (isSubmitted, returnedModel) { + if (isSubmitted) { + //Here is where the entry is actually added to the model + model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel); + } + }); + } else { + growl.error("That predefined indexer is already configured."); //For now this is the only case where adding is forbidden so we use this hardcoded message "for now"... (;-)) + } + } - function calculateTextAndColor() { - if ($scope.indexer.state === "DISABLED_USER") { - $scope.offText = "Disabled by user"; - $scope.offColor = "default"; - } else if ($scope.indexer.state === "DISABLED_SYSTEM_TEMPORARY") { - $scope.offText = "Temporary disabled"; - $scope.offColor = "warning"; - } else if ($scope.indexer.state === "DISABLED_SYSTEM") { - $scope.offText = "Disabled by system"; - $scope.offColor = "danger"; + function checkAddingAllowed(existingIndexers, preset) { + if (!preset || !(preset.searchModuleType === "ANIZB" || preset.searchModuleType === "BINSEARCH" || preset.searchModuleType === "NZBINDEX" || preset.searchModuleType === "NZBCLUB")) { + return true; + } + return !_.any(existingIndexers, function (existingEntry) { + return existingEntry.name === preset.name; + }); + } + + $scope.newznabPresets = [ + { + name: "abNZB", + host: "https://abnzb.com/" + }, + { + name: "altHUB", + host: "https://api.althub.co.za" + }, + { + name: "Animetosho (Newznab)", + host: "https://feed.animetosho.org", + categories: ["Anime"], + supportedSearchIds: [], + supportedSearchTypes: ["SEARCH"], + allCapsChecked: true, + configComplete: true, + categoryMapping: { + anime: 5070, + audiobook: null, + comic: null, + ebook: null, + magazine: null, + categories: [ + { + id: 5070, + name: "Anime", + subCategories: [] + } + ] } + }, + { + name: "Digital Carnage", + host: "https://digitalcarnage.info" + }, + { + name: "DogNZB", + host: "https://api.dognzb.cr" + }, + { + name: "Drunken Slug", + host: "https://api.drunkenslug.com" + }, + { + name: "FastNZB", + host: "https://fastnzb.com" + }, + { + name: "LuluNZB", + host: "https://lulunzb.com" + }, + { + name: "miatrix", + host: "https://www.miatrix.com" + }, + { + name: "NZB Finder", + host: "https://nzbfinder.ws" + }, + { + name: "NZBCat", + host: "https://nzb.cat" + }, + { + name: "nzb.su", + host: "https://api.nzb.su" + }, + { + name: "NZBGeek", + host: "https://api.nzbgeek.info" + }, + { + name: "NzbNdx", + host: "https://www.nzbndx.com" + }, + { + name: "NzBNooB", + host: "https://www.nzbnoob.com" + }, + { + name: "NzbNation", + host: "http://www.nzbnation.com/" + }, + { + name: "nzbplanet", + host: "https://nzbplanet.net" + }, + { + name: "omgwtfnzbs", + host: "https://api.omgwtfnzbs.org" + }, + { + name: "SceneNZBs", + host: "https://scenenzbs.com", + info: "If you want german or spanish (or other language specific) results make sure to add the newznab IDs in the categories config.
          For example for german UHD movies add 2145.
          You can find out the IDs by browsing https://scenenzbs.com/rss." + }, + { + name: "spotweb.com", + host: "https://spotweb.me" + }, + { + name: "Tabula-Rasa", + host: "https://www.tabula-rasa.pw/api/v1/" + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + hitLimit: null, + hitLimitResetTime: null, + host: "https://binsearch.info", + loadLimitOnRandom: null, + name: "Binsearch", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "BINSEARCH", + username: null + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + generalMinSize: 1, + hitLimit: null, + hitLimitResetTime: null, + host: "https://nzbindex.com", + loadLimitOnRandom: null, + name: "NZBIndex", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "NZBINDEX", + username: null + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + generalMinSize: 1, + hitLimit: null, + hitLimitResetTime: null, + host: "https://api.nzbindex.com", + loadLimitOnRandom: null, + name: "NZBIndex API", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "NZBINDEX_API", + username: null + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + generalMinSize: 1, + hitLimit: null, + hitLimitResetTime: null, + host: "https://beta.nzbindex.com/search", + loadLimitOnRandom: null, + name: "NZBIndex Beta", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "NZBINDEX_BETA", + username: null + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + hitLimit: null, + hitLimitResetTime: null, + host: "https://www.nzbking.com/search", + loadLimitOnRandom: null, + name: "NZBKing.com", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "NZBKING", + username: null + }, + { + allCapsChecked: true, + enabledForSearchSource: "INTERNAL", + categories: [], + configComplete: true, + downloadLimit: null, + generalMinSize: 1, + hitLimit: null, + hitLimitResetTime: null, + host: null, + loadLimitOnRandom: null, + name: "WtfNzb", + password: null, + preselect: true, + score: 0, + showOnSearch: true, + state: "ENABLED", + supportedSearchIds: [], + supportedSearchTypes: [], + timeout: null, + searchModuleType: "WTFNZB", + username: null, + userAgent: null } + ]; - calculateTextAndColor(); + $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) { + return entry.name.toLowerCase() + }); - $scope.onChange = function () { - if (initialized) { - //Skip on first call when initial value is set - $scope.indexer.state = $scope.value ? "ENABLED" : "DISABLED_USER"; - calculateTextAndColor(); - } - initialized = true; + $scope.torznabPresets = [ + { + allCapsChecked: false, + configComplete: false, + name: "Jackett/Cardigann", + host: "http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/", + supportedSearchIds: undefined, + supportedSearchTypes: undefined, + searchModuleType: "TORZNAB", + state: "ENABLED", + enabledForSearchSource: "BOTH" + }, + { + categories: ["Anime"], + allCapsChecked: true, + configComplete: true, + name: "Animetosho (Torznab)", + host: "https://feed.animetosho.org", + supportedSearchIds: [], + supportedSearchTypes: ["SEARCH"], + searchModuleType: "TORZNAB", + state: "ENABLED", + enabledForSearchSource: "BOTH" } - } -} -/* - * (C) Copyright 2017 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. - */ - -angular - .module('nzbhydraApp') - .directive('indexerSelectionButton', indexerSelectionButton); + ]; -function indexerSelectionButton() { - controller.$inject = ["$scope"]; - return { - templateUrl: 'static/html/directives/indexer-selection-button.html', - scope: { - selectedIndexers: "=", - availableIndexers: "=", - btn: "@" - }, - controller: controller + $scope.emptyTorznabPreset = { + allCapsChecked: false, + configComplete: false, + supportedSearchIds: undefined, + supportedSearchTypes: undefined, + searchModuleType: "TORZNAB", + state: "ENABLED", + enabledForSearchSource: "BOTH" }; + $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) { + return entry.name.toLowerCase() + }); +}]); - function controller($scope) { - $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers, - function (indexer) { - return indexer.searchModuleType === "TORZNAB"; - } - ); +angular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$http", "model", "form", "fields", "isInitial", "parentModel", "growl", "IndexerCheckBeforeCloseService", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) { - $scope.invertSelection = function () { - _.forEach($scope.availableIndexers, function (x) { - var index = _.indexOf($scope.selectedIndexers, x.name); - if (index === -1) { - $scope.selectedIndexers.push(x.name); - } else { - $scope.selectedIndexers.splice(index, 1); + $scope.model = model; + $scope.fields = fields; + $scope.isInitial = isInitial; + $scope.spinnerActive = false; + $scope.needsConnectionTest = false; + + $scope.obSubmit = function () { + if (model.searchModuleType === 'JACKETT_CONFIG') { + $uibModalInstance.close(model); + } else if (form.$valid) { + var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) { + if (angular.isDefined(data)) { + $scope.model = data; } + $uibModalInstance.close(data); + }); + } else { + growl.error("Config invalid. Please check your settings."); + angular.forEach(form.$error, function (error) { + angular.forEach(error, function (field) { + field.$setTouched(); + }); }); - }; - - $scope.selectAll = function () { - $scope.deselectAll(); - $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, "name")); - }; - - $scope.deselectAll = function () { - $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length); - }; - - function selectByPredicate(predicate) { - $scope.deselectAll(); - $scope.selectedIndexers.push.apply($scope.selectedIndexers, - _.pluck( - _.filter($scope.availableIndexers, - predicate - ), "name") - ); } + }; - $scope.reset = function () { - selectByPredicate(function (indexer) { - return indexer.preselect; - }); - }; + $scope.cancel = function () { + $uibModalInstance.dismiss(); + }; - $scope.selectAllUsenet = function () { - selectByPredicate(function (indexer) { - return indexer.searchModuleType !== "TORZNAB"; - }); - }; + $scope.deleteEntry = function () { + parentModel.splice(parentModel.indexOf(model), 1); + $uibModalInstance.close($scope); + }; - $scope.selectAllTorrent = function () { - selectByPredicate(function (indexer) { - return indexer.searchModuleType === "TORZNAB"; - }); + $scope.reset = function () { + //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf)) + $scope.options.resetModel(); + $scope.options.resetModel(); + }; + + $scope.$on("modal.closing", function (targetScope, reason) { + if (reason === "backdrop click") { + $scope.reset($scope); } - } -} + }); +}]); angular .module('nzbhydraApp') - .directive('indexerInput', indexerInput); + .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl); -function indexerInput() { - controller.$inject = ["$scope"]; - return { - templateUrl: 'static/html/directives/indexer-input.html', - scope: { - indexer: "=", - model: "=", - onClick: "=" - }, - replace: true, - controller: controller - }; +function CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) { - function controller($scope) { - $scope.isFocused = false; + var updateMessagesInterval = undefined; - $scope.onFocus = function () { - $scope.isFocused = true; - }; + $scope.messages = undefined; + $http.post("internalapi/indexer/checkCaps", capsCheckRequest).then(function (response) { + $scope.$close([response.data, capsCheckRequest.indexerConfig]); + if (response.data.length === 0) { + growl.info("No indexers were checked"); + } + }, function () { + $scope.$dismiss("Unknown error") + }); + + $timeout( + updateMessagesInterval = $interval(function () { + $http.get("internalapi/indexer/checkCapsMessages").then(function (response) { + var map = response.data; + var messages = []; + for (var name in map) { + if (map.hasOwnProperty(name)) { + for (var i = 0; i < map[name].length; i++) { + var message = ""; + if (capsCheckRequest.checkType !== "SINGLE") { + message += name + ": "; + } + message += map[name][i]; + messages.push(message); + } + } + } + $scope.messages = messages; + }); - $scope.onBlur = function () { - $scope.isFocused = false; - }; + }, 500), + 500); - var expiryWarning; - if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== "Lifetime") { - var expiryDate = moment($scope.indexer.vipExpirationDate, "YYYY-MM-DD"); - if (expiryDate < moment()) { - console.log("Expiry date reached for indexer " + $scope.indexer.name); - expiryWarning = "VIP access expired on " + $scope.indexer.vipExpirationDate; - } else if (expiryDate.subtract(7, 'days') < moment()) { - console.log("Expiry date near for indexer " + $scope.indexer.name); - expiryWarning = "VIP access will expire on " + $scope.indexer.vipExpirationDate; - } - } - $scope.expiryWarning = expiryWarning; - if ($scope.indexer.color !== null) { - $scope.style = "background-color: " + $scope.indexer.color.replace("rgb", "rgba").replace(")", ",0.5)") + $scope.$on('$destroy', function () { + if (angular.isDefined(updateMessagesInterval)) { + $interval.cancel(updateMessagesInterval); } - } - + }); } - angular .module('nzbhydraApp') - .directive('hydraupdates', hydraupdates); + .factory('IndexerConfigBoxService', IndexerConfigBoxService); + +function IndexerConfigBoxService($http, $q, $uibModal) { -function hydraupdates() { - controller.$inject = ["$scope", "UpdateService"]; return { - templateUrl: 'static/html/directives/updates.html', - controller: controller + checkConnection: checkConnection, + checkCaps: checkCaps }; - function controller($scope, UpdateService) { + function checkConnection(url, settings) { + var deferred = $q.defer(); - $scope.loadingPromise = UpdateService.getInfos().then(function (response) { - $scope.currentVersion = response.data.currentVersion; - $scope.latestVersion = response.data.latestVersion; - $scope.latestVersionIsBeta = response.data.latestVersionIsBeta; - $scope.betaVersion = response.data.betaVersion; - $scope.updateAvailable = response.data.updateAvailable; - $scope.betaUpdateAvailable = response.data.betaUpdateAvailable; - $scope.latestVersionIgnored = response.data.latestVersionIgnored; - $scope.changelog = response.data.changelog; - $scope.updatedExternally = response.data.updatedExternally; - $scope.wrapperOutdated = response.data.wrapperOutdated; - $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally; - if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) { - $scope.updateAvailable = false; + $http.post(url, settings).then(function (result) { + //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click + if (result.data.successful) { + deferred.resolve({checked: true, message: null, model: result.data}); + } else { + deferred.reject({checked: true, message: result.data.message}); } + }, function (result) { + deferred.reject({checked: false, message: result.data.message}); }); - UpdateService.getVersionHistory().then(function (response) { - $scope.versionHistory = response.data; - }); + return deferred.promise; + } + function checkCaps(capsCheckRequest) { + var deferred = $q.defer(); - $scope.update = function (version) { - UpdateService.update(version); - }; + var result = $uibModal.open({ + templateUrl: 'static/html/checker-state.html', + controller: CheckCapsModalInstanceCtrl, + size: "md", + backdrop: "static", + backdropClass: "waiting-cursor", + resolve: { + capsCheckRequest: function () { + return capsCheckRequest; + } + } + }); - $scope.showChangelog = function (version) { - UpdateService.showChanges(version); - }; + result.result.then(function (data) { + deferred.resolve(data[0], data[1]); + }, function (message) { + deferred.reject(message); + }); - $scope.forceUpdate = function () { - UpdateService.update($scope.latestVersion) - }; + return deferred.promise; } -} +} angular .module('nzbhydraApp') - .directive('hydraNews', hydraNews); + .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService); + +function IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) { -function hydraNews() { - controller.$inject = ["$scope", "$http"]; return { - templateUrl: "static/html/directives/news.html", - controller: controller + checkBeforeClose: checkBeforeClose }; - function controller($scope, $http) { + function checkBeforeClose(scope, model) { + var deferred = $q.defer(); + if (model.searchModuleType === 'JACKETT_CONFIG') { + deferred.resolve(model); + } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) { + checkCapsWhenClosing(scope, model).then(function () { + deferred.resolve(model); + }, function () { + deferred.reject(); + }); + } else { + scope.spinnerActive = true; + blockUI.start("Testing connection..."); + var url = "internalapi/indexer/checkConnection"; + IndexerConfigBoxService.checkConnection(url, model).then(function () { + growl.info("Connection to the indexer tested successfully"); + checkCapsWhenClosing(scope, model).then(function (data) { + scope.spinnerActive = false; + blockUI.reset(); + deferred.resolve(data); + }, function () { + scope.spinnerActive = false; + blockUI.reset(); + deferred.reject(); + }); + }, + function (data) { + scope.spinnerActive = false; + blockUI.reset(); + handleConnectionCheckFail(ModalService, data, model, "indexer", deferred); + }); + } + return deferred.promise; + } - return $http.get("internalapi/news").then(function (response) { - $scope.news = response.data; - }); + //Called when the indexer dialog is closed + function checkCapsWhenClosing(scope, model) { + var deferred = $q.defer(); + if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) { + blockUI.start("New indexer found. Testing its capabilities. This may take a bit..."); + IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: "SINGLE"}).then( + function (data) { + data = data[0]; //We get a list of results (with one result because the check type is single) + blockUI.reset(); + scope.spinnerActive = false; + if (data.allCapsChecked && data.configComplete) { + growl.info("Successfully tested capabilites of indexer"); + } else if (!data.allCapsChecked && data.configComplete) { + ModalService.open("Incomplete caps check", "The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
          Until then some search types or IDs may not be usable.", {}, "md", "left"); + } else if (!data.configComplete) { + ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); + } + deferred.resolve(data.indexerConfig); + }, + function () { + blockUI.reset(); + scope.spinnerActive = false; + model.supportedSearchIds = undefined; + model.supportedSearchTypes = undefined; + ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.", {}, "md", "left"); + deferred.resolve(); + }).finally( + function () { + scope.spinnerActive = false; + }) + } else { + deferred.resolve(); + } + return deferred.promise; } } +/* + * (C) Copyright 2017 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. + */ +DownloaderConfigBoxService.$inject = ["$http", "$q", "$uibModal"]; +DownloaderCheckBeforeCloseService.$inject = ["$q", "DownloaderConfigBoxService", "growl", "ModalService", "blockUI"]; +angular + .module('nzbhydraApp') + .config(["formlyConfigProvider", function config(formlyConfigProvider) { + formlyConfigProvider.setType({ + name: 'downloaderConfig', + templateUrl: 'static/html/config/downloader-config.html', + controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) { + $scope.formOptions = {formState: $scope.formState}; + $scope._showBox = _showBox; + $scope.showBox = showBox; + $scope.isInitial = false; + $scope.presets = [ + { + name: "NZBGet", + downloaderType: "NZBGET", + username: "nzbgetx", + nzbAddingType: "UPLOAD", + nzbAccessType: "REDIRECT", + iconCssClass: "", + downloadType: "NZB", + url: "http://nzbget:tegbzn6789@localhost:6789" + }, + { + url: "http://localhost:8080", + downloaderType: "SABNZBD", + name: "SABnzbd", + nzbAddingType: "UPLOAD", + nzbAccessType: "REDIRECT", + iconCssClass: "", + downloadType: "NZB" + } + ]; -LogModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "entry"]; -escapeHtml.$inject = ["$sanitize"];angular - .module('nzbhydraApp') - .directive('hydralog', hydralog); + function _showBox(model, parentModel, isInitial, callback) { + var modalInstance = $uibModal.open({ + templateUrl: 'static/html/config/downloader-config-box.html', + controller: 'DownloaderConfigBoxInstanceController', + size: 'lg', + resolve: { + model: function () { + //Isn't properly stored in parentmodel for some reason, this works just as well + model.showAdvanced = localStorageService.get("showAdvanced"); + console.log(model.showAdvanced); + return model; + }, + fields: function () { + return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService); + }, + isInitial: function () { + return isInitial + }, + parentModel: function () { + return parentModel; + }, + data: function () { + return $scope.options.data; + } + } + }); -function hydralog() { - controller.$inject = ["$scope", "$http", "$interval", "$uibModal", "$sce", "localStorageService", "growl"]; - return { - templateUrl: "static/html/directives/log.html", - controller: controller - }; - function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) { - $scope.tailInterval = null; - $scope.doUpdateLog = localStorageService.get("doUpdateLog") !== null ? localStorageService.get("doUpdateLog") : false; - $scope.doTailLog = localStorageService.get("doTailLog") !== null ? localStorageService.get("doTailLog") : false; + modalInstance.result.then(function (returnedModel) { + $scope.form.$setDirty(true); + if (angular.isDefined(callback)) { + callback(true, returnedModel); + } + }, function () { + if (angular.isDefined(callback)) { + callback(false); + } + }); + } - $scope.active = 0; - $scope.currentJsonIndex = 0; - $scope.hasMoreJsonLines = true; + function showBox(model, parentModel) { + $scope._showBox(model, parentModel, false) + } - function getLog(index) { - if ($scope.active === 0) { - return $http.get("internalapi/debuginfos/jsonlogs", { - params: { - offset: index, - limit: 500 + $scope.addEntry = function (entriesCollection, preset) { + var model = angular.copy({ + enabled: true + }); + if (angular.isDefined(preset)) { + _.extend(model, preset); } - }).then(function (response) { - var data = response.data; - $scope.jsonLogLines = angular.fromJson(data.lines); - $scope.hasMoreJsonLines = data.hasMore; - }); - } else if ($scope.active === 1) { - return $http.get("internalapi/debuginfos/currentlogfile").then(function (response) { - var data = response.data; - $scope.log = $sce.trustAsHtml(data.replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'")); - }, function (data) { - growl.error(data) - }); - } else if ($scope.active === 2) { - return $http.get("internalapi/debuginfos/logfilenames").then(function (response) { - $scope.logfilenames = response.data; - }); - } - } - - $scope.logPromise = getLog(); - $scope.select = function (index) { - $scope.active = index; - $scope.update(); - }; + $scope.isInitial = true; - $scope.scrollToBottom = function () { - document.getElementById("logfile").scrollTop = 10000000; - document.getElementById("logfile").scrollTop = 100001000; - }; + $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) { + if (isSubmitted) { + //Here is where the entry is actually added to the model + entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model); + } + }); + }; - $scope.update = function () { - getLog($scope.currentJsonIndex); - if ($scope.active === 1) { - $scope.scrollToBottom(); - } - }; + function getDownloaderBoxFields(model, parentModel, isInitial) { + var fieldset = []; - $scope.getOlderFormatted = function () { - getLog($scope.currentJsonIndex + 500).then(function () { - $scope.currentJsonIndex += 500; - }); + fieldset = _.union(fieldset, [ + { + key: 'enabled', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Enabled' + } + }, + { + key: 'name', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Name', + required: true + }, + validators: { + uniqueName: { + expression: function (viewValue) { + if (isInitial || viewValue !== model.name) { + return _.pluck(parentModel, "name").indexOf(viewValue) === -1; + } + return true; + }, + message: '"Downloader \\"" + $viewValue + "\\" already exists"' + } + } - }; + }, + { + key: 'url', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'URL', + help: 'URL with scheme and full path', + required: true + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + } + ]); - $scope.getNewerFormatted = function () { - var index = Math.max($scope.currentJsonIndex - 500, 0); - getLog(index); - $scope.currentJsonIndex = index; - }; - function startUpdateLogInterval() { - $scope.tailInterval = $interval(function () { - if ($scope.active === 1) { - $scope.update(); - if ($scope.doTailLog && $scope.active === 1) { - $scope.scrollToBottom(); + if (model.downloaderType === "SABNZBD") { + fieldset.push({ + key: 'apiKey', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'API Key' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + }) + } else if (model.downloaderType === "NZBGET") { + fieldset.push({ + key: 'username', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Username' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + }); + fieldset.push({ + key: 'password', + type: 'passwordSwitch', + templateOptions: { + type: 'text', + label: 'Password' + }, + watcher: { + listener: function (field, newValue, oldValue, scope) { + if (newValue !== oldValue) { + scope.$parent.needsConnectionTest = true; + } + } + } + }) } - } - }, 5000); - } - - $scope.toggleUpdate = function (doUpdateLog) { - $scope.doUpdateLog = doUpdateLog; - if ($scope.doUpdateLog) { - startUpdateLogInterval(); - } else if ($scope.tailInterval !== null) { - console.log("Cancelling"); - $interval.cancel($scope.tailInterval); - localStorageService.set("doTailLog", false); - $scope.doTailLog = false; - } - localStorageService.set("doUpdateLog", $scope.doUpdateLog); - }; - $scope.toggleTailLog = function () { - localStorageService.set("doTailLog", $scope.doTailLog); - }; + fieldset = _.union(fieldset, [ + { + key: 'defaultCategory', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Default category', + help: 'When adding NZBs this category will be used instead of asking for the category. Write "Use original category", "Use no category" or "Use mapped category" to not be asked.', + placeholder: 'Ask when downloading' + } + }, + { + key: 'nzbAddingType', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'NZB adding type', + options: [ + {name: 'Send link', value: 'SEND_LINK'}, + {name: 'Upload NZB', value: 'UPLOAD'} + ], + help: "How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.", + tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' + + '
          Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.', + advanced: true + } + }, + { + key: 'addPaused', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Add paused', + help: 'Add NZBs paused', + advanced: true + } + }, + { + key: 'iconCssClass', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Icon CSS class', + help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. "film")', + placeholder: 'Default', + tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.', + advanced: true + } + } + ]); - $scope.openModal = function openModal(entry) { - var modalInstance = $uibModal.open({ - templateUrl: 'log-entry.html', - controller: LogModalInstanceCtrl, - size: "xl", - resolve: { - entry: function () { - return entry; - } + return fieldset; } - }); - - modalInstance.result.then(); - }; - - $scope.$on('$destroy', function () { - if ($scope.tailInterval !== null) { - $interval.cancel($scope.tailInterval); } }); + }]); - if ($scope.doUpdateLog) { - startUpdateLogInterval(); - } - - - } -} - -angular - .module('nzbhydraApp') - .controller('LogModalInstanceCtrl', LogModalInstanceCtrl); - -function LogModalInstanceCtrl($scope, $uibModalInstance, entry) { - - $scope.entry = entry; - - $scope.ok = function () { - $uibModalInstance.dismiss(); - }; -} - -angular - .module('nzbhydraApp') - .filter('formatTimestamp', formatTimestamp); - -function formatTimestamp() { - return function (date) { - //1579392000 - //1579374757 - if (date === null || date === undefined) { - return null; - } - if (date < 1979374757) { - date *= 1000; - } - return moment(date).local().format("YYYY-MM-DD HH:mm"); - } -} - -angular - .module('nzbhydraApp') - .filter('escapeHtml', escapeHtml); - -function escapeHtml($sanitize) { - return function (text) { - return $sanitize(text); - } -} angular .module('nzbhydraApp') - .filter('formatClassname', formatClassname); - -function formatClassname() { - return function (fqn) { - return fqn.substr(fqn.lastIndexOf(".") + 1); - - } -} -/* - * (C) Copyright 2017 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. - */ + .factory('DownloaderConfigBoxService', DownloaderConfigBoxService); -NewsModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "news"]; -WelcomeModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$state", "MigrationService"]; -angular - .module('nzbhydraApp') - .directive('hydraChecksFooter', hydraChecksFooter); +function DownloaderConfigBoxService($http, $q, $uibModal) { -function hydraChecksFooter() { - controller.$inject = ["$scope", "UpdateService", "RequestsErrorHandler", "HydraAuthService", "$http", "$uibModal", "ConfigService", "GenericStorageService", "ModalService", "growl", "NotificationService", "bootstrapped"]; return { - templateUrl: 'static/html/directives/checks-footer.html', - controller: controller + checkConnection: checkConnection, + checkCaps: checkCaps }; - function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) { - $scope.updateAvailable = false; - $scope.checked = false; - var welcomeIsBeingShown = false; - - $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin; + function checkConnection(url, settings) { + var deferred = $q.defer(); - $scope.$on("user:loggedIn", function () { - if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) { - retrieveUpdateInfos(); + $http.post(url, settings).then(function (result) { + //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click + if (result.data.successful) { + deferred.resolve({checked: true, message: null, model: result.data}); + } else { + deferred.reject({checked: true, message: result.data.message}); } - }); - - function checkForOutOfMemoryException() { - GenericStorageService.get("outOfMemoryDetected", false).then(function (response) { - if (response.data !== "" && response.data) { - //headline, message, params, size, textAlign - ModalService.open("Out of memory error detected", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', { - yes: { - text: "OK" - } - }, undefined, "left"); - GenericStorageService.put("outOfMemoryDetected", false, false); - } - }); - } - - function checkForOpenToInternet() { - GenericStorageService.get("showOpenToInternetWithoutAuth", false).then(function (response) { - if (response.data !== "" && response.data) { - //headline, message, params, size, textAlign - ModalService.open("Security issue - open to internet", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', { - yes: { - text: "OK" - } - }, undefined, "left"); - GenericStorageService.put("showOpenToInternetWithoutAuth", false, false); - } - }); - } - - console.log("Checking for below Java 17."); - - function checkForJavaBelow17() { - GenericStorageService.get("belowJava17", false).then(function (response) { - if (response.data !== "" && response.data) { - console.log("Java below 17"); - //headline, message, params, size, textAlign - ModalService.open("Java version below 17", 'You\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', { - yes: { - text: "OK" - } - }, undefined, "left"); - GenericStorageService.put("belowJava17", false, false); - } - }); - } - - console.log("Checking for failed backup."); - - function checkForFailedBackup() { - GenericStorageService.get("FAILED_BACKUP", false).then(function (response) { - if (response.data !== "" && response.data && !response.data) { - console.log("Failed backup detected"); - //headline, message, params, size, textAlign - ModalService.open("Failed backup", 'The creation of a backup file has failed. Error message: \"' + response.data.message + '."
          For details please check the log around ' + response.data.time + '.', { - yes: { - text: "OK" - } - }, undefined, "left"); - GenericStorageService.put("FAILED_BACKUP", false, null); - } - }); - } - - function checkForOutdatedWrapper() { - $http.get("internalapi/updates/isDisplayWrapperOutdated").then(function (response) { - var data = response.data; - if (data !== undefined && data !== null && data) { - ModalService.open("Outdated wrappers detected", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.

          \n' + - ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder.
          \n' + - ' For Windows these files are:\n' + - '
            \n' + - '
          • NZBHydra2.exe
          • \n' + - '
          • NZBHydra2 Console.exe
          • \n' + - '
          \n' + - ' For linux these files are:\n' + - '
            \n' + - '
          • nzbhydra2
          • \n' + - '
          • nzbhydra2wrapper.py
          • \n' + - '
          • nzbhydra2wrapperPy3.py
          • \n' + - '
          \n' + - ' Make sure to overwrite all of these files that already exist - you don\'t need to update any files that aren\'t already present.\n' + - '

          \n' + - ' Afterwards start NZBHydra again.', { - yes: { - text: "OK", - onYes: function () { - $http.put("internalapi/updates/setOutdatedWrapperDetectedWarningShown") - } - } - }, undefined, "left"); - - } - }); - } - - if ($scope.mayUpdate) { - retrieveUpdateInfos(); - checkForOutOfMemoryException(); - checkForOutdatedWrapper(); - checkForOpenToInternet(); - checkForJavaBelow17(); - checkForFailedBackup(); - } - - function retrieveUpdateInfos() { - $scope.checked = true; - UpdateService.getInfos().then(function (response) { - if (response) { - $scope.currentVersion = response.data.currentVersion; - $scope.latestVersion = response.data.latestVersion; - $scope.latestVersionIsBeta = response.data.latestVersionIsBeta; - $scope.updateAvailable = response.data.updateAvailable; - $scope.changelog = response.data.changelog; - $scope.updatedExternally = response.data.updatedExternally; - $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally; - $scope.showWhatsNewBanner = response.data.showWhatsNewBanner; - if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) { - $scope.updateAvailable = false; - } - $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice; - + }, function (result) { + deferred.reject({checked: false, message: result.data.message}); + }); - $scope.$emit("showUpdateFooter", $scope.updateAvailable); - $scope.$emit("showAutomaticUpdateFooter", $scope.automaticUpdateToNotice); - } else { - $scope.$emit("showUpdateFooter", false); - } - }); - } + return deferred.promise; + } - $scope.update = function () { - UpdateService.update($scope.latestVersion); - }; + function checkCaps(capsCheckRequest) { + var deferred = $q.defer(); - $scope.ignore = function () { - UpdateService.ignore($scope.latestVersion); - $scope.updateAvailable = false; - $scope.$emit("showUpdateFooter", $scope.updateAvailable); - }; + var result = $uibModal.open({ + templateUrl: 'static/html/checker-state.html', + controller: CheckCapsModalInstanceCtrl, + size: "md", + backdrop: "static", + backdropClass: "waiting-cursor", + resolve: { + capsCheckRequest: function () { + return capsCheckRequest; + } + } + }); - $scope.showChangelog = function () { - UpdateService.showChanges($scope.latestVersion); - }; + result.result.then(function (data) { + deferred.resolve(data[0], data[1]); + }, function (message) { + deferred.reject(message); + }); - $scope.showChangesFromAutomaticUpdate = function () { - UpdateService.showChangesFromAutomaticUpdate(); - $scope.automaticUpdateToNotice = null; - $scope.$emit("showAutomaticUpdateFooter", false); - }; + return deferred.promise; + } +} - $scope.dismissChangesFromAutomaticUpdate = function () { - $scope.automaticUpdateToNotice = null; - $scope.$emit("showAutomaticUpdateFooter", false); - console.log("Dismissing showAutomaticUpdateFooter"); - return $http.get("internalapi/updates/ackAutomaticUpdateVersionHistory").then(function (response) { - }); - }; +angular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', ["$scope", "$q", "$uibModalInstance", "$http", "model", "fields", "isInitial", "parentModel", "data", "growl", "DownloaderCheckBeforeCloseService", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) { - function checkAndShowNews() { - RequestsErrorHandler.specificallyHandled(function () { - if (ConfigService.getSafe().showNews) { - $http.get("internalapi/news/forcurrentversion").then(function (response) { - var data = response.data; - if (data && data.length > 0) { - $uibModal.open({ - templateUrl: 'static/html/news-modal.html', - controller: NewsModalInstanceCtrl, - size: "lg", - resolve: { - news: function () { - return data; - } - } - }); - $http.put("internalapi/news/saveshown"); - } - }); - } - }); - } + $scope.model = model; + $scope.fields = fields; + $scope.isInitial = isInitial; + $scope.spinnerActive = false; + $scope.needsConnectionTest = false; - function checkExpiredIndexers() { - _.each(ConfigService.getSafe().indexers, function (indexer) { - if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== "Lifetime") { - var expiryWarning; - var expiryDate = moment(indexer.vipExpirationDate, "YYYY-MM-DD"); - var messagePrefix = "VIP access for indexer " + indexer.name; - if (expiryDate < moment()) { - expiryWarning = messagePrefix + " expired on " + indexer.vipExpirationDate; - } else if (expiryDate.subtract(7, 'days') < moment()) { - expiryWarning = messagePrefix + " will expire on " + indexer.vipExpirationDate; - } - if (expiryWarning) { - console.log(expiryWarning); - growl.warning(expiryWarning); - } + $scope.obSubmit = function () { + if ($scope.form.$valid) { + var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) { + if (angular.isDefined(data)) { + $scope.model = data; } + $uibModalInstance.close(data); }); - } - - function checkAndShowWelcome() { - RequestsErrorHandler.specificallyHandled(function () { - $http.get("internalapi/welcomeshown").then(function (response) { - if (!response.data) { - $http.put("internalapi/welcomeshown"); - var promise = $uibModal.open({ - templateUrl: 'static/html/welcome-modal.html', - controller: WelcomeModalInstanceCtrl, - size: "md" - }); - promise.opened.then(function () { - welcomeIsBeingShown = true; - }); - promise.closed.then(function () { - welcomeIsBeingShown = false; - }); - } else { - if (HydraAuthService.getUserInfos().maySeeAdmin) { - _.defer(checkAndShowNews); - _.defer(checkExpiredIndexers); - } - } - }, function () { - console.log("Error while checking for welcome") + } else { + growl.error("Config invalid. Please check your settings."); + angular.forEach($scope.form.$error, function (error) { + angular.forEach(error, function (field) { + field.$setTouched(); }); }); } + }; - checkAndShowWelcome(); + $scope.cancel = function () { + $uibModalInstance.dismiss(); + }; - function showUnreadNotifications(unreadNotifications, stompClient) { - if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) { - growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true}); - for (var i = 0; i < unreadNotifications.length; i++) { - if (unreadNotifications[i].id === undefined) { - console.log("Undefined ID found for notification " + unreadNotifications[i]); - continue; - } - stompClient.send("/app/markNotificationRead", {}, unreadNotifications[i].id); - } - return; - } - for (var j = 0; j < unreadNotifications.length; j++) { - var notification = unreadNotifications[j]; - var body = notification.body.replace("\n", "
          "); - switch (notification.messageType) { - case "INFO": - growl.info(body); - break; - case "SUCCESS": - growl.success(body); - break; - case "WARNING": - growl.warning(body); - break; - case "FAILURE": - growl.danger(body); - break; - } - if (notification.id === undefined) { - console.log("Undefined ID found for notification " + unreadNotifications[i]); - continue; - } - stompClient.send("/app/markNotificationRead", {}, notification.id); - } - } + $scope.deleteEntry = function () { + parentModel.splice(parentModel.indexOf(model), 1); + $uibModalInstance.close($scope); + }; - if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) { - var socket = new SockJS(bootstrapped.baseUrl + 'websocket'); - var stompClient = Stomp.over(socket); - stompClient.debug = null; - stompClient.connect({}, function (frame) { - stompClient.subscribe('/topic/notifications', function (message) { - showUnreadNotifications(JSON.parse(message.body), stompClient); - }); - }); + $scope.reset = function () { + if (angular.isDefined(data.resetFunction)) { + //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf)) + $scope.options.resetModel(); + $scope.options.resetModel(); } + }; - } -} - -angular - .module('nzbhydraApp') - .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl); + $scope.$on("modal.closing", function (targetScope, reason) { + if (reason === "backdrop click") { + $scope.reset($scope); + } + }); +}]); -function NewsModalInstanceCtrl($scope, $uibModalInstance, news) { - $scope.news = news; - $scope.close = function () { - $uibModalInstance.dismiss(); - }; -} angular .module('nzbhydraApp') - .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl); + .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService); -function WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) { - $scope.close = function () { - $uibModalInstance.dismiss(); - }; +function DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) { - $scope.startMigration = function () { - $uibModalInstance.dismiss(); - MigrationService.migrate(); + return { + checkBeforeClose: checkBeforeClose }; - $scope.goToConfig = function () { - $uibModalInstance.dismiss(); - $state.go("root.config.main"); + function checkBeforeClose(scope, model) { + var deferred = $q.defer(); + if (!scope.isInitial && !scope.needsConnectionTest) { + deferred.resolve(); + } else { + scope.spinnerActive = true; + blockUI.start("Testing connection..."); + var url = "internalapi/downloader/checkConnection"; + DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () { + blockUI.reset(); + scope.spinnerActive = false; + growl.info("Connection to the downloader tested successfully"); + deferred.resolve(); + }, + function (data) { + blockUI.reset(); + scope.spinnerActive = false; + handleConnectionCheckFail(ModalService, data, model, "downloader", deferred); + }).finally(function () { + scope.spinnerActive = false; + blockUI.reset(); + }); + } + return deferred.promise; } } - /* * (C) Copyright 2017 TheOtherP (theotherp@posteo.net) * @@ -7657,1201 +5301,3558 @@ function WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationSe * limitations under the License. */ +hashCode = function (s) { + return s.split("").reduce(function (a, b) { + a = ((a << 5) - a) + b.charCodeAt(0); + return a & a + }, 0); +}; + +angular + .module('nzbhydraApp').run(["formlyConfig", "formlyValidationMessages", function (formlyConfig, formlyValidationMessages) { + formlyValidationMessages.addStringMessage('required', 'This field is required'); + formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid'); + formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted'; +}]); + angular .module('nzbhydraApp') - .directive('footer', footer); + .config(["formlyConfigProvider", function config(formlyConfigProvider) { + formlyConfigProvider.extras.removeChromeAutoComplete = true; + formlyConfigProvider.extras.explicitAsync = true; + formlyConfigProvider.disableWarnings = window.onProd; -function footer() { - controller.$inject = ["$scope", "$http", "$uibModal", "ConfigService", "GenericStorageService", "bootstrapped"]; - return { - templateUrl: 'static/html/directives/footer.html', - controller: controller - }; - function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) { - $scope.updateFooterBottom = 0; + formlyConfigProvider.setWrapper({ + name: 'settingWrapper', + templateUrl: 'setting-wrapper.html' + }); - var safeConfig = bootstrapped.safeConfig; - $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) { - return x.enabled - }).length > 0; - $scope.showUpdateFooter = false; - $scope.$on("showDownloaderStatus", function (event, doShow) { - $scope.showDownloaderStatus = doShow; - updateFooterBottom(); - updatePaddingBottom(); + formlyConfigProvider.setWrapper({ + name: 'fieldset', + templateUrl: 'fieldset-wrapper.html', + controller: ['$scope', function ($scope) { + $scope.tooltipIsOpen = false; + }] }); - $scope.$on("showUpdateFooter", function (event, doShow) { - $scope.showUpdateFooter = doShow; - updateFooterBottom(); - updatePaddingBottom(); + + formlyConfigProvider.setType({ + name: 'help', + template: [ + '
          ', + '
          ', + '
          ', + '
          {{ line | derefererExtracting | unsafe }}
          ', + '
          ', + '
          ', + '
          ' + ].join(' ') }); - $scope.$on("showAutomaticUpdateFooter", function (event, doShow) { - $scope.showAutomaticUpdateFooter = doShow; - updateFooterBottom(); - updatePaddingBottom(); + + + formlyConfigProvider.setWrapper({ + name: 'logicalGroup', + template: [ + '' + ].join(' ') }); - function updateFooterBottom() { + formlyConfigProvider.setType({ + name: 'horizontalInput', + extends: 'input', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); - if ($scope.showDownloaderStatus) { - if ($scope.showAutomaticUpdateFooter) { - $scope.updateFooterBottom = 20; - } else { - $scope.updateFooterBottom = 38; - } - } else { - $scope.updateFooterBottom = 0; - } - } + formlyConfigProvider.setType({ + name: 'horizontalTextArea', + extends: 'textarea', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); - function updatePaddingBottom() { - var paddingBottom = 0; - if ($scope.showDownloaderStatus) { - paddingBottom += 30; + formlyConfigProvider.setType({ + name: 'timeOfDay', + extends: 'horizontalInput', + controller: ['$scope', function ($scope) { + $scope.model[$scope.options.key] = moment.utc($scope.model[$scope.options.key]).toDate(); + }] + }); + + formlyConfigProvider.setType({ + name: 'passwordSwitch', + extends: 'horizontalInput', + template: [ + '
          ', + '', + '', + '', + '
          ' + ].join(' '), + controller: function ($scope) { + $scope.hidePassword = true; } - if ($scope.showUpdateFooter) { - paddingBottom += 40; + }); + + formlyConfigProvider.setType({ + name: 'horizontalChips', + extends: 'horizontalInput', + template: '' + + ' ' + + '
          ' + + ' {{chip}}' + + ' ' + + '
          ' + + '
          ' + + ' ' + + '
          ' + }); + + formlyConfigProvider.setType({ + name: 'percentInput', + template: [ + '' + ].join(' ') + }); + + formlyConfigProvider.setType({ + name: 'apiKeyInput', + template: [ + '
          ', + '', + '', + '', + '
          ' + ].join(' '), + controller: function ($scope) { + $scope.generate = function () { + var result = ""; + var length = 24; + var chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + for (var i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)]; + $scope.model[$scope.options.key] = result; + $scope.form.$setDirty(true); + } } - $scope.paddingBottom = paddingBottom; - document.getElementById("wrap").classList.remove("padding-bottom-0"); - document.getElementById("wrap").classList.remove("padding-bottom-30"); - document.getElementById("wrap").classList.remove("padding-bottom-40"); - document.getElementById("wrap").classList.remove("padding-bottom-70"); - var paddingBottomClass = "padding-bottom-" + paddingBottom; - document.getElementById("wrap").classList.add(paddingBottomClass); - } + }); - updatePaddingBottom(); + formlyConfigProvider.setType({ + name: 'fileInput', + extends: 'horizontalInput', + template: [ + '
          ', + '', + '', + '', + '
          ' + ].join(' '), + controller: function ($scope, FileSelectionService) { + $scope.open = function () { + FileSelectionService.open($scope.model[$scope.options.key], $scope.to.type).then(function (selection) { + $scope.model[$scope.options.key] = selection; + }); + } + } + }); - updateFooterBottom(); + formlyConfigProvider.setType({ + name: 'colorInput', + extends: 'horizontalInput', + templateUrl: 'static/html/config/color-control.html', + controller: function ($scope) { + //Model format: rgb(116,18,18) + //Input format: rgba(100,42,41,0.5) + if (!_.isNullOrEmpty($scope.model.color)) { + $scope.color = $scope.model.color; + } + $scope.convertColorToCss = function () { + if (_.isNullOrEmpty($scope.model.color)) { + return ""; + } + return $scope.model.color.replace("rgb", "rgba").replace(")", ",0.5)"); + } + $scope.convertColorFromInput = function () { + if (_.isNullOrEmpty($scope.color)) { + return; + } + $scope.model.color = $scope.color.replace("rgba", "rgb").replace(",0.5)", ")"); + } + $scope.clear = function () { + $scope.model.color = null; + $scope.color = null; + } + $scope.$watch("model.color", function () { + if (!_.isNullOrEmpty($scope.model.color)) { + $scope.color = $scope.model.color; + } + }) + } + }); + formlyConfigProvider.setType({ + name: 'testConnection', + templateUrl: 'button-test-connection.html' + }); - } -} + formlyConfigProvider.setType({ + name: 'horizontalTestConnection', + extends: 'testConnection', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); + formlyConfigProvider.setType({ + name: 'customMappingTest', + extends: 'horizontalInput', + template: [ + '
          ', + '', + '
          ' + ].join(' '), + controller: function ($scope, $uibModal, $http) { + $scope.open = function () { + var model = $scope.model; + var modelCopy = structuredClone(model); + $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(); -angular - .module('nzbhydraApp').directive('focusOn', focusOn); + } -function focusOn() { - return directive; + $scope.test = function () { + if (!$scope.exampleInput) { + $scope.exampleResult = "Empty example data"; + return; - function directive(scope, elem, attr) { - scope.$on('focusOn', function (e, name) { - if (name === attr.focusOn) { - elem[0].focus(); + } + console.log("custom mapping test"); + $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).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" + }) + } } }); - } -} -/* - * (C) Copyright 2017 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. - */ - -angular - .module('nzbhydraApp') - .directive('downloaderStatusFooter', downloaderStatusFooter); - -function downloaderStatusFooter() { - controller.$inject = ["$scope", "$http", "RequestsErrorHandler", "HydraAuthService", "$interval", "bootstrapped"]; - return { - templateUrl: 'static/html/directives/downloader-status-footer.html', - controller: controller - }; + function updateIndexerModel(model, indexerConfig) { + model.supportedSearchIds = indexerConfig.supportedSearchIds; + model.supportedSearchTypes = indexerConfig.supportedSearchTypes; + model.categoryMapping = indexerConfig.categoryMapping; + model.configComplete = indexerConfig.configComplete; + model.allCapsChecked = indexerConfig.allCapsChecked; + model.hitLimit = indexerConfig.hitLimit; + model.downloadLimit = indexerConfig.downloadLimit; + model.state = indexerConfig.state; + model.backend = indexerConfig.backend; + } - function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) { + formlyConfigProvider.setType({ + //BUtton + name: 'checkCaps', + templateUrl: 'button-check-caps.html', + controller: function ($scope, IndexerConfigBoxService, ModalService, growl) { + $scope.message = ""; + $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host); - var downloaderStatus; - var updateInterval = null; - console.log("websocket"); - var socket = new SockJS(bootstrapped.baseUrl + 'websocket'); - var stompClient = Stomp.over(socket); - stompClient.debug = null; - stompClient.connect({}, function (frame) { - stompClient.subscribe('/topic/downloaderStatus', function (message) { - downloaderStatus = JSON.parse(message.body); - updateFooter(downloaderStatus); - }); - stompClient.send("/app/connectDownloaderStatus", function (message) { - downloaderStatus = JSON.parse(message.body); - updateFooter(downloaderStatus); - }) - }); + var testButton = "#button-check-caps-" + $scope.uniqueId; + var testMessage = "#message-check-caps-" + $scope.uniqueId; + function showSuccess() { + angular.element(testButton).removeClass("btn-default"); + angular.element(testButton).removeClass("btn-danger"); + angular.element(testButton).removeClass("btn-warning"); + angular.element(testButton).addClass("btn-success"); + } - $scope.$emit("showDownloaderStatus", true); - var downloadRateCounter = 0; + function showError() { + angular.element(testButton).removeClass("btn-default"); + angular.element(testButton).removeClass("btn-warning"); + angular.element(testButton).removeClass("btn-success"); + angular.element(testButton).addClass("btn-danger"); + } - $scope.downloaderChart = { - options: { - chart: { - type: 'stackedAreaChart', - height: 35, - width: 300, - margin: { - top: 5, - right: 0, - bottom: 0, - left: 0 - }, - x: function (d) { - return d.x; - }, - y: function (d) { - return d.y; - }, - interactive: true, - useInteractiveGuideline: false, - transitionDuration: 0, - showControls: false, - showLegend: false, - showValues: false, - duration: 0, - tooltip: { - valueFormatter: function (d, i) { - return d + " kb/s"; - }, - keyFormatter: function () { - return ""; - }, - id: "downloader-status-tooltip" - }, - css: "float:right;" + function showWarning() { + angular.element(testButton).removeClass("btn-default"); + angular.element(testButton).removeClass("btn-danger"); + angular.element(testButton).removeClass("btn-success"); + angular.element(testButton).addClass("btn-warning"); } - }, - data: [{values: [], key: "Bla", color: '#00a950'}], - config: { - refreshDataOnly: true, - deepWatchDataDepth: 0, - deepWatchData: false, - deepWatchOptions: false - } - }; - function updateFooter() { - if (downloaderStatus.lastUpdateForNow && updateInterval === null) { - //Server will send no new status updates for a while because the last two retrieved statuses are the same. - //We must still update the footer so that the graph doesn't stand still - console.debug("Retrieved last update for now, starting update interval"); - updateInterval = $interval(function () { - //Just put the last known rate at the end to keep it going - $scope.downloaderChart.data[0].values.splice(0, 1); - $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate}); - try { - $scope.api.update(); - } catch (ignored) { - } - if (_.every($scope.downloaderChart.data[0].values, function (value) { - return value === downloaderStatus.lastDownloadRate - })) { - //The bar has been filled with the latest known value, we can now stop until we get a new update - console.debug("Filled the bar with last known value, stopping update interval"); - $interval.cancel(updateInterval); - updateInterval = null; - } - }, 1000); - } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) { - //New data is incoming, cancel interval - console.debug("Got new update, stopping update interval") - $interval.cancel(updateInterval); - updateInterval = null; - } - $scope.foo = downloaderStatus; - $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo'; - $scope.foo.url = downloaderStatus.url; - //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated - var maxEntriesHistory = 200; - if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) { - //Not yet full, just fill up - console.debug("Adding data, filling bar with initial values") - for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) { - if (i >= downloaderStatus.downloadingRatesInKilobytes.length) { - break; - } - $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]}); - } - } else { - console.debug("Adding data, moving bar") - //Remove first one, add to the end - $scope.downloaderChart.data[0].values.splice(0, 1); - $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate}); - } - try { - $scope.api.update(); - } catch (ignored) { - } - if ($scope.foo.state === "DOWNLOADING") { - $scope.foo.buttonClass = "play"; - } else if ($scope.foo.state === "PAUSED") { - $scope.foo.buttonClass = "pause"; - } else if ($scope.foo.state === "OFFLINE") { - $scope.foo.buttonClass = "off"; - } else { - $scope.foo.buttonClass = "time"; - } - $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase(); - //Bad but without the state isn't updated - $scope.$apply(); - } + //When button is clicked + $scope.checkCaps = function () { + angular.element(testButton).addClass("glyphicon-refresh-animate"); + IndexerConfigBoxService.checkCaps({ + indexerConfig: $scope.model, + checkType: "SINGLE" + }).then(function (data) { + data = data[0]; //We get a list of results (with one result because the check type is single) + //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves + updateIndexerModel($scope.model, data.indexerConfig); + if (data.indexerConfig.supportedSearchIds.length > 0) { + var message = "Supports " + data.indexerConfig.supportedSearchIds; + angular.element(testMessage).text(message); + } + if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) { + showSuccess(); + growl.info("Successfully tested capabilites of indexer"); + $scope.form.capsChecked = true; + } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) { + showWarning(); + ModalService.open("Incomplete caps check", "The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
          Until then some search types or IDs may not be usable.", {}, "md", "left"); + $scope.form.capsChecked = true; + } else if (!data.configComplete) { + showError(); + ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); + } + }, function (message) { + angular.element(testMessage).text(message); + showError(); + ModalService.open("Error testing capabilities", "An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box", {}, "md", "left"); + }).finally(function () { + angular.element(testButton).removeClass("glyphicon-refresh-animate"); + }); + } + } + }); - } -} + formlyConfigProvider.setType({ + name: 'horizontalCheckCaps', + extends: 'checkCaps', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); -angular - .module('nzbhydraApp') - .directive('downloadNzbzipButton', downloadNzbzipButton); + formlyConfigProvider.setType({ + name: 'horizontalApiKeyInput', + extends: 'apiKeyInput', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); -function downloadNzbzipButton() { - controller.$inject = ["$scope", "growl", "$http", "FileDownloadService"]; - return { - templateUrl: 'static/html/directives/download-nzbzip-button.html', - require: ['^searchResults'], - scope: { - searchResults: "<", - searchTitle: "<", - callback: "&" - }, - controller: controller - }; + formlyConfigProvider.setType({ + name: 'horizontalPercentInput', + extends: 'percentInput', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); - function controller($scope, growl, $http, FileDownloadService) { - $scope.download = function () { - if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) { - growl.info("You should select at least one result..."); - } else { - var values = _.map($scope.searchResults, function (value) { - return value.searchResultId; - }); - var link = "internalapi/nzbzip"; + formlyConfigProvider.setType({ + name: 'switch', + template: '
          ' + }); - var searchTitle; - if (angular.isDefined($scope.searchTitle)) { - searchTitle = " for " + $scope.searchTitle.replace("[^a-zA-Z0-9.-]", "_"); - } else { - searchTitle = ""; + formlyConfigProvider.setType({ + name: 'indexerStateSwitch', + template: '' + }); + + + formlyConfigProvider.setType({ + name: 'horizontalIndexerStateSwitch', + extends: 'indexerStateSwitch', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); + + + formlyConfigProvider.setType({ + name: 'duoSetting', + extends: 'input', + defaultOptions: { + className: 'col-md-9', + templateOptions: { + type: 'number', + noRow: true, + label: '' } - var filename = "NZBHydra NZBs" + searchTitle + ".zip"; - $http({method: "post", url: link, data: values}).then(function (response) { - if (response.data.successful && response.data.zip !== null) { - link = "internalapi/nzbzipDownload"; - FileDownloadService.downloadFile(link, filename, "POST", response.data.zipFilepath); - if (angular.isDefined($scope.callback)) { - $scope.callback({result: response.data.addedIds}); - } - if (response.data.missedIds.length > 0) { - growl.error("Unable to add " + response.missedIds.length + " out of " + values.length + " NZBs to ZIP"); - } - } else { - growl.error(response.data.message); - } - }, function (data, status, headers, config) { - growl.error(status); - }); } - } - } -} + }); + formlyConfigProvider.setType({ + name: 'horizontalSwitch', + extends: 'switch', + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); -angular - .module('nzbhydraApp') - .directive('downloadNzbsButton', downloadNzbsButton); + formlyConfigProvider.setType({ + name: 'horizontalSelect', + extends: 'select', + wrapper: ['settingWrapper', 'bootstrapHasError'], + controller: function ($scope) { + if ($scope.options.templateOptions.optionsFunction !== undefined) { + $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model)); + } + if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) { + $scope.options.templateOptions.optionsFunctionAfter($scope.model); + } + } + }); -function downloadNzbsButton() { - controller.$inject = ["$scope", "$http", "NzbDownloadService", "ConfigService", "growl"]; - return { - templateUrl: 'static/html/directives/download-nzbs-button.html', - require: ['^searchResults'], - scope: { - searchResults: "<", - callback: "&" - }, - controller: controller - }; - function controller($scope, $http, NzbDownloadService, ConfigService, growl) { + formlyConfigProvider.setType({ + name: 'horizontalMultiselect', + defaultOptions: { + templateOptions: { + optionsAttr: 'bs-options', + ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search' + } + }, + template: '', + controller: function ($scope) { + var settings = $scope.to.settings || []; + settings.classes = settings.classes || []; + angular.extend(settings.classes, ["form-control"]); + $scope.settings = settings; + if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) { + $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model)); + } + $scope.events = { + onToggleItem: function (item, newValue) { + $scope.form.$setDirty(true); + } + } + }, + wrapper: ['settingWrapper', 'bootstrapHasError'] + }); - $scope.downloaders = NzbDownloadService.getEnabledDownloaders(); - $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null; + formlyConfigProvider.setType({ + name: 'label', + template: '' + }); - $scope.download = function (downloader) { - if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) { - growl.info("You should select at least one result..."); - } else { + formlyConfigProvider.setType({ + name: 'duolabel', + extends: 'label', + defaultOptions: { + className: 'col-md-2', + templateOptions: { + label: '-' + } + } + }); - var didFilterOutResults = false; - var didKeepAnyResults = false; - var searchResults = _.filter($scope.searchResults, function (value) { - if (value.downloadType === "NZB") { - didKeepAnyResults = true; - return true; - } else { - console.log("Not sending torrent result to downloader"); - didFilterOutResults = true; - return false; - } - }); - if (didFilterOutResults && !didKeepAnyResults) { - growl.info("None of the selected results were NZBs. Adding aborted"); - if (angular.isDefined($scope.callback)) { - $scope.callback({result: []}); - } - return; - } else if (didFilterOutResults && didKeepAnyResults) { - growl.info("Some the selected results are torrent results which were skipped"); + formlyConfigProvider.setType({ + name: 'repeatSection', + templateUrl: 'repeatSection.html', + controller: function ($scope) { + $scope.formOptions = {formState: $scope.formState}; + $scope.addNew = addNew; + $scope.remove = remove; + $scope.copyFields = copyFields; + + function copyFields(fields) { + fields = angular.copy(fields); + $scope.repeatfields = fields; + return fields; } - var tos = _.map(searchResults, function (entry) { - return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory} - }); + $scope.clear = function (field) { + return _.mapObject(field, function (key, val) { + if (typeof val === 'object') { + return $scope.clear(val); + } + return undefined; - NzbDownloadService.download(downloader, tos).then(function (response) { - if (angular.isDefined(response.data)) { - if (response !== "dismissed") { - if (response.data.successful) { - if (response.data.message == null) { - growl.info("Successfully added all NZBs"); - } else { - growl.warning(response.data.message); + }); + }; + + function addNew(preset) { + console.log(preset); + $scope.form.$setDirty(true); + $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || []; + var repeatsection = $scope.model[$scope.options.key]; + var newsection = angular.copy($scope.options.templateOptions.defaultModel); + Object.assign(newsection, preset); + repeatsection.push(newsection); + } + + function remove($index) { + $scope.model[$scope.options.key].splice($index, 1); + $scope.form.$setDirty(true); + } + } + }); + + formlyConfigProvider.setType({ + name: 'recheckAllCaps', + templateUrl: 'static/html/config/recheck-all-caps.html', + controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) { + $scope.recheck = function (checkType) { + IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) { + //A bit ugly, but we have to update the current model with the new data from the list + for (var i = 0; i < $scope.model.length; i++) { + for (var j = 0; j < listOfResults.length; j++) { + if ($scope.model[i].name === listOfResults[j].indexerConfig.name) { + updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig); + $scope.form.$setDirty(true); } - } else { - growl.error(response.data.message); } - } else { - growl.error("Error while adding NZBs"); } - if (angular.isDefined($scope.callback)) { - $scope.callback({result: response.data.addedIds}); - } - } - }, function () { - growl.error("Error while adding NZBs"); - }); - } - }; - - $scope.sendToBlackhole = function () { - var didFilterOutResults = false; - var didKeepAnyResults = false; - var searchResults = _.filter($scope.searchResults, function (value) { - if (value.downloadType === "TORRENT") { - didKeepAnyResults = true; - return true; - } else { - console.log("Not sending NZB result to black hole"); - didFilterOutResults = true; - return false; - } - }); - if (didFilterOutResults && !didKeepAnyResults) { - growl.info("None of the selected results were torrents. Adding aborted"); - if (angular.isDefined($scope.callback)) { - $scope.callback({result: []}); + }); } - return; - } else if (didFilterOutResults && didKeepAnyResults) { - growl.info("Some the selected results are NZB results which were skipped"); } - var searchResultIds = _.pluck(searchResults, "searchResultId"); - $http.put("internalapi/saveTorrent", searchResultIds).then(function (response) { - if (response.data.successful) { - growl.info("Successfully saved all torrents"); - } else { - growl.error(response.data.message); - } - if (angular.isDefined($scope.callback)) { - $scope.callback({result: response.data.addedIds}); - } - }); - } + }); - } -} + formlyConfigProvider.setType({ + name: 'notificationSection', + templateUrl: 'notificationRepeatSection.html', + controller: function ($scope, NotificationService) { + $scope.formOptions = {formState: $scope.formState}; + $scope.addNew = addNew; + $scope.remove = remove; + $scope.copyFields = copyFields; + $scope.eventTypes = []; + var allData = NotificationService.getAllData(); + _.each(_.keys(allData), function (key) { + $scope.eventTypes.push({"key": key, "label": allData[key].readable}) + }) -freetextFilter.$inject = ["DebugService"]; -booleanFilter.$inject = ["DebugService"];angular - .module('nzbhydraApp').directive("columnFilterWrapper", columnFilterWrapper); + function copyFields(fields) { + fields = angular.copy(fields); + $scope.repeatfields = fields; + return fields; + } -function columnFilterWrapper() { - controller.$inject = ["$scope", "DebugService"]; - return { - restrict: "E", - templateUrl: 'static/html/dataTable/columnFilterOuter.html', - transclude: true, - controllerAs: 'columnFilterWrapperCtrl', - scope: { - inline: "@" - }, - bindToController: true, - controller: controller, - link: function (scope, element, attr, ctrl) { - scope.element = element; - } - }; + $scope.clear = function (field) { + return _.mapObject(field, function (key, val) { + if (typeof val === 'object') { + return $scope.clear(val); + } + return undefined; - function controller($scope, DebugService) { - var vm = this; + }); + }; - vm.open = false; - vm.isActive = false; + function addNew(eventType) { + $scope.form.$setDirty(true); + $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || []; + var repeatsection = $scope.model[$scope.options.key]; + var newsection = angular.copy($scope.options.templateOptions.defaultModel); - vm.toggle = function () { - vm.open = !vm.open; - if (vm.open) { - $scope.$broadcast("opened"); + var eventTypeData = NotificationService.getAllData()[eventType]; + console.log(eventTypeData); + newsection.eventType = eventType; + newsection.titleTemplate = eventTypeData.titleTemplate; + newsection.bodyTemplate = eventTypeData.bodyTemplate; + newsection.messageType = eventTypeData.messageType; + + repeatsection.push(newsection); + } + + function remove($index) { + $scope.model[$scope.options.key].splice($index, 1); + $scope.form.$setDirty(true); + } } - }; + }); - vm.clear = function () { - if (vm.open) { - $scope.$broadcast("clear"); + formlyConfigProvider.setType({ + //Button + name: 'testNotification', + templateUrl: 'button-test-notification.html', + controller: function ($scope, NotificationService) { + + + //When button is clicked + $scope.testNotification = function () { + NotificationService.testNotification($scope.model.eventType) + } } - }; + }); - $scope.$on("filter", function (event, column, filterModel, isActive, open) { - vm.open = open || false; - vm.isActive = isActive; + formlyConfigProvider.setType({ + name: 'horizontalTestNotification', + extends: 'testNotification', + wrapper: ['settingWrapper', 'bootstrapHasError'] }); - DebugService.log("filter-wrapper"); - } -} + }]); -angular - .module('nzbhydraApp').directive("freetextFilter", freetextFilter); -function freetextFilter(DebugService) { - controller.$inject = ["$scope", "focus"]; +ConfigService.$inject = ["$http", "$q", "$cacheFactory", "$uibModal", "bootstrapped", "RequestsErrorHandler"];angular + .module('nzbhydraApp') + .factory('ConfigService', ConfigService); + +function ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) { + + ConfigureInModalInstanceCtrl.$inject = ["$scope", "$uibModalInstance", "$http", "growl", "$interval", "RequestsErrorHandler", "localStorageService", "externalTool", "dialogInfo"]; + var cache = $cacheFactory("nzbhydra"); + var safeConfig = bootstrapped.safeConfig; + return { - template: '', - require: "^columnFilterWrapper", - controllerAs: 'innerController', - scope: { - column: "@", - onKey: "@", - placeholder: "@", - tooltip: "@" - }, - controller: controller + set: set, + get: get, + getSafe: getSafe, + invalidateSafe: invalidateSafe, + maySeeAdminArea: maySeeAdminArea, + reloadConfig: reloadConfig, + apiHelp: apiHelp, + configureIn: configureIn }; - function controller($scope, focus) { - $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper - $scope.data = {}; - $scope.tooltip = $scope.tooltip || ""; + function set(newConfig, ignoreWarnings) { + var deferred = $q.defer(); + $http.put('internalapi/config', newConfig) + .then(function (response) { + if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) { + cache.put("config", newConfig); + setTimeout(function () { + invalidateSafe(); + }, 500) + } + deferred.resolve(response); - $scope.$on("opened", function () { - focus("freetext-filter-input"); + }, function (errorresponse) { + console.log("Error saving settings:"); + console.log(errorresponse); + deferred.reject(errorresponse); + }); + return deferred.promise; + } + + function reloadConfig() { + return $http.get('internalapi/config/reload').then(function (response) { + return response.data; }); + } - function emitFilterEvent(isOpen) { - isOpen = $scope.inline || isOpen; - $scope.$emit("filter", $scope.column, { - filterValue: $scope.data.filter, - filterType: "freetext" - }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen); + function apiHelp() { + return $http.get('internalapi/config/apiHelp').then(function (response) { + return response.data; + }); + } + + function get() { + var config = cache.get("config"); + if (angular.isUndefined(config)) { + config = $http.get('internalapi/config').then(function (response) { + return response.data; + }); + cache.put("config", config); + } + + return config; + } + + function getSafe() { + return safeConfig; + } + + function invalidateSafe() { + RequestsErrorHandler.specificallyHandled(function () { + $http.get('internalapi/config/safe').then(function (response) { + safeConfig = response.data; + }); + }); + + } + + function maySeeAdminArea() { + function loadAll() { + var maySeeAdminArea = cache.get("maySeeAdminArea"); + if (!angular.isUndefined(maySeeAdminArea)) { + var deferred = $q.defer(); + deferred.resolve(maySeeAdminArea); + return deferred.promise; + } + + return $http.get('internalapi/mayseeadminarea') + .then(function (configResponse) { + var config = configResponse.data; + cache.put("maySeeAdminArea", config); + return configResponse.data; + }); } - $scope.$on("clear", function () { - //Don't clear but close window (event is fired when clicked outside) - emitFilterEvent(false); + return loadAll().then(function (maySeeAdminArea) { + return maySeeAdminArea; }); + } - $scope.onKeyUp = function (keyEvent) { - if (keyEvent.which === 13 || $scope.onKey) { - emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed + function configureIn(externalTool) { + $uibModal.open({ + templateUrl: 'static/html/configure-in-modal.html', + controller: ConfigureInModalInstanceCtrl, + size: "md", + resolve: { + externalTool: function () { + return externalTool; + }, + dialogInfo: function () { + return $http.get("internalapi/externalTools/getDialogInfo").then(function (response) { + return response.data; + }) + } } - }; - DebugService.log("filter-freetext"); + }) } -} -angular - .module('nzbhydraApp').directive("checkboxesFilter", checkboxesFilter); + function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) { + var lastConfig = localStorageService.get(externalTool); -function checkboxesFilter() { - controller.$inject = ["$scope", "DebugService"]; - return { - template: '', - controllerAs: 'checkboxesFilterController', - scope: { - column: "@", - entries: "<", - preselect: "<", - showInvert: "<", - isBoolean: "<" - }, - controller: controller - }; + $scope.externalTool = externalTool; + $scope.externalToolDisplayName = externalTool; + $scope.externalToolsMessages = []; + $scope.closeButtonType = "warning"; + $scope.completed = false; + $scope.working = false; + $scope.showMessages = false; - function controller($scope, DebugService) { - $scope.selected = { - entries: [] - }; - $scope.active = false; + $scope.nzbhydraHost = dialogInfo.nzbhydraHost; + $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured; + $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured; + $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured; + $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured; + $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured; + $scope.addDisabledIndexers = false; - if ($scope.preselect) { - $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries); + if (!$scope.configureForUsenet && !$scope.configureForTorrents) { + growl.error("No usenet or torrent indexers configured"); } - $scope.invert = function () { - $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries); - }; - $scope.selectAll = function () { - $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries); - }; + $scope.nzbhydraName = "NZBHydra2"; + $scope.xdarrHost = "http://localhost:" + $scope.addType = "SINGLE"; + $scope.enableRss = true; + $scope.enableAutomaticSearch = true; + $scope.enableInteractiveSearch = true; + $scope.categories = null; + $scope.animeCategories = null; + $scope.priority = 0; + $scope.useHydraPriorities = true; - $scope.deselectAll = function () { - $scope.selected.entries.splice(0, $scope.selected.entries.length); - }; + if (externalTool === "Sonarr" || externalTool === "Sonarrv3") { + $scope.xdarrHost += "8989"; + $scope.categories = "5030,5040"; + if (externalTool === "Sonarrv3") { + $scope.externalToolDisplayName = "Sonarr v3+"; + } + } else if (externalTool === "Radarr" || externalTool === "Radarrv3") { + $scope.xdarrHost += "7878"; + $scope.categories = "2000"; + if (externalTool === "Radarrv3") { + $scope.externalToolDisplayName = "Radarr v3+"; + } + } else if (externalTool === "Lidarr") { + $scope.xdarrHost += "8686"; + $scope.categories = "3000"; + } else if (externalTool === "Readarr") { + $scope.xdarrHost += "8787"; + $scope.categories = "7020,8010"; + } + $scope.removeYearFromSearchString = false; - $scope.apply = function () { - $scope.active = $scope.selected.entries.length < $scope.entries.length; - $scope.$emit("filter", $scope.column, { - filterValue: _.pluck($scope.selected.entries, "id"), - filterType: "checkboxes", - isBoolean: $scope.isBoolean - }, $scope.active) - }; - $scope.clear = function () { - $scope.selectAll(); - $scope.active = false; - $scope.$emit("filter", $scope.column, { - filterValue: undefined, - filterType: "checkboxes", - isBoolean: $scope.isBoolean - }, $scope.active) + if (lastConfig !== null && lastConfig !== undefined) { + Object.assign($scope, lastConfig); + } + + $scope.close = function () { + $uibModalInstance.dismiss(); }; - $scope.$on("clear", $scope.clear); - DebugService.log("filter-checkboxes"); - } -} -angular - .module('nzbhydraApp').directive("booleanFilter", booleanFilter); + $scope.submit = function (deleteOnly) { + if ($scope.completed && !deleteOnly) { + $uibModalInstance.dismiss(); + } + if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) { + growl.error("No usenet or torrent indexers configured"); + return; + } + $scope.externalToolsMessages = []; + $scope.spinnerActive = true; + $scope.working = true; + $scope.showMessages = true; + var data = { -function booleanFilter(DebugService) { - controller.$inject = ["$scope"]; - return { - template: '', - controllerAs: 'booleanFilterController', - scope: { - column: "@", - options: "<", - preselect: "@" - }, - controller: controller - }; + nzbhydraName: $scope.nzbhydraName, + externalTool: $scope.externalTool, + nzbhydraHost: $scope.nzbhydraHost, + addType: deleteOnly ? "DELETE_ONLY" : $scope.addType, + xdarrHost: $scope.xdarrHost, + xdarrApiKey: $scope.xdarrApiKey, + enableRss: $scope.enableRss, + enableAutomaticSearch: $scope.enableAutomaticSearch, + enableInteractiveSearch: $scope.enableInteractiveSearch, + categories: $scope.categories, + animeCategories: $scope.animeCategories, + removeYearFromSearchString: $scope.removeYearFromSearchString, + earlyDownloadLimit: $scope.earlyDownloadLimit, + multiLanguages: $scope.multiLanguages, + configureForUsenet: $scope.configureForUsenet, + configureForTorrents: $scope.configureForTorrents, + additionalParameters: $scope.additionalParameters, + minimumSeeders: $scope.minimumSeeders, + seedRatio: $scope.seedRatio, + seedTime: $scope.seedTime, + seasonPackSeedTime: $scope.seasonPackSeedTime, + discographySeedTime: $scope.discographySeedTime, + addDisabledIndexers: $scope.addDisabledIndexers, + priority: $scope.priority, + useHydraPriorities: $scope.useHydraPriorities + } + localStorageService.set(externalTool, data); - function controller($scope) { - $scope.selected = {value: $scope.options[$scope.preselect].value}; - $scope.active = false; + function updateMessages() { + $http.get("internalapi/externalTools/messages").then(function (response) { + $scope.externalToolsMessages = response.data; + }); + } - $scope.apply = function () { - $scope.active = $scope.selected.value !== $scope.options[0].value; - $scope.$emit("filter", $scope.column, { - filterValue: $scope.selected.value, - filterType: "boolean" - }, $scope.active) - }; - $scope.clear = function () { - $scope.selected.value = true; - $scope.active = false; - $scope.$emit("filter", $scope.column, {filterValue: undefined, filterType: "boolean"}, $scope.active) + var updateInterval = $interval(function () { + updateMessages(); + }, 500); + + RequestsErrorHandler.specificallyHandled(function () { + $scope.completed = false; + $http.post("internalapi/externalTools/configure", data).then(function (response) { + updateMessages(); + $interval.cancel(updateInterval); + $scope.spinnerActive = false; + console.log(response); + if (response.data) { + $scope.completed = true; + $scope.closeButtonType = "success"; + } else { + $scope.working = false; + $scope.completed = false; + } + }, function (error) { + updateMessages(); + console.error(error.data); + $interval.cancel(updateInterval); + $scope.completed = false; + $scope.spinnerActive = false; + $scope.working = false; + }); + }); }; - $scope.$on("clear", $scope.clear); - DebugService.log("filter-boolean"); + } } +/* + * (C) Copyright 2017 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. + */ + +ConfigFields.$inject = ["$injector"]; angular - .module('nzbhydraApp').directive("timeFilter", timeFilter); + .module('nzbhydraApp') + .factory('ConfigFields', ConfigFields); -function timeFilter() { - controller.$inject = ["$scope", "DebugService"]; +function ConfigFields($injector) { return { - template: '', - scope: { - column: "@", - selected: "<" - }, - controller: controller + getFields: getFields }; - function controller($scope, DebugService) { - - $scope.dateOptions = { - dateDisabled: false, - formatYear: 'yy', - startingDay: 1 - }; - - $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate']; - $scope.format = $scope.formats[0]; - $scope.altInputFormats = ['M!/d!/yyyy']; - $scope.active = false; - - $scope.openAfter = function () { - $scope.after.opened = true; - }; - - $scope.openBefore = function () { - $scope.before.opened = true; + function ipValidator() { + return { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + if (value) { + return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value) + || /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(value); + } + return true; + }, + message: '$viewValue + " is not a valid IP Address"' }; + } - $scope.after = { - opened: false + function regexValidator(regex, message, prefixViewValue, preventEmpty) { + return { + expression: function ($viewValue, $modelValue) { + var value = $modelValue || $viewValue; + if (value) { + if (Array.isArray(value)) { + for (var i = 0; i < value.length; i++) { + if (!regex.test(value[i])) { + return false; + } + } + return true; + } else { + return regex.test(value); + } + } + return !preventEmpty; + }, + message: (prefixViewValue ? '$viewValue + " ' : '" ') + message + '"' }; + } - $scope.before = { - opened: false - }; + function getFields(rootModel, showAdvanced) { + return { + main: [ + { + wrapper: 'fieldset', + templateOptions: {label: 'Hosting'}, + fieldGroup: [ + { + key: 'host', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Host', + required: true, + placeholder: 'IPv4 address to bind to', + help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.' + }, + validators: { + ipAddress: ipValidator() + } + }, + { + key: 'port', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Port', + required: true, + placeholder: '5076', + help: 'Requires restart.' + }, + validators: { + port: regexValidator(/^\d{1,5}$/, "is no valid port", true) + } + }, + { + key: 'urlBase', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'URL base', + placeholder: '/nzbhydra', + help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.', + tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like "/nzbhydra". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.', + advanced: true + }, + validators: { + urlBase: regexValidator(/^((\/.*[^\/])|\/)$/, 'URL base has to start and may not end with /', false, true) + } - $scope.apply = function () { - $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate; - $scope.$emit("filter", $scope.column, { - filterValue: { - after: $scope.selected.afterDate, - before: $scope.selected.beforeDate - }, filterType: "time" - }, $scope.active) - }; - $scope.clear = function () { - $scope.selected.beforeDate = undefined; - $scope.selected.afterDate = undefined; - $scope.active = false; - $scope.$emit("filter", $scope.column, {filterValue: undefined, filterType: "time"}, $scope.active) - }; - $scope.$on("clear", $scope.clear); - DebugService.log("filter-time"); - } -} + }, + { + key: 'ssl', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Use SSL', + help: 'Requires restart.', + tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\'s more secure and can be configured better.', + advanced: true + } + }, + { + key: 'sslKeyStore', + hideExpression: '!model.ssl', + type: 'fileInput', + templateOptions: { + label: 'SSL keystore file', + required: true, + type: "file", + help: 'Requires restart. See wiki.' + } + }, + { + key: 'sslKeyStorePassword', + hideExpression: '!model.ssl', + type: 'horizontalInput', + templateOptions: { + type: 'password', + label: 'SSL keystore password', + required: true, + help: 'Requires restart.' + } + } -angular - .module('nzbhydraApp').directive("numberRangeFilter", numberRangeFilter); -function numberRangeFilter() { - controller.$inject = ["$scope", "DebugService"]; - return { - template: '', - scope: { - column: "@", - min: "<", - max: "<", - addon: "@", - tooltip: "@" - }, - controller: controller - }; + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Proxy', + tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.', + advanced: true + } + , + fieldGroup: [ + { + key: 'proxyType', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'Use proxy', + options: [ + {name: 'None', value: 'NONE'}, + {name: 'SOCKS', value: 'SOCKS'}, + {name: 'HTTP(S)', value: 'HTTP'} + ] + } + }, + { + key: 'proxyHost', + type: 'horizontalInput', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'text', + label: 'SOCKS proxy host', + placeholder: 'Set to use a SOCKS proxy', + help: "IPv4 only" + } + }, + { + key: 'proxyPort', + type: 'horizontalInput', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'number', + label: 'Proxy port', + placeholder: '1080' + } + }, + { + key: 'proxyUsername', + type: 'horizontalInput', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'text', + label: 'Proxy username' + } + }, + { + key: 'proxyPassword', + type: 'passwordSwitch', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'text', + label: 'Proxy password' + } + }, + { + key: 'proxyIgnoreLocal', + type: 'horizontalSwitch', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'switch', + label: 'Bypass local network addresses' + } + }, + { + key: 'proxyIgnoreDomains', + type: 'horizontalChips', + hideExpression: 'model.proxyType==="NONE"', + templateOptions: { + type: 'text', + help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.', + label: 'Bypass domains' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: {label: 'UI'}, + fieldGroup: [ - function controller($scope, DebugService) { - $scope.filterValue = {min: undefined, max: undefined}; - $scope.active = false; + { + key: 'theme', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'Theme', + options: [ + {name: 'Auto', value: 'auto'}, + {name: 'Grey', value: 'grey'}, + {name: 'Bright', value: 'bright'}, + {name: 'Dark', value: 'dark'} + ] + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: {label: 'Security'}, + fieldGroup: [ + { + key: 'apiKey', + type: 'horizontalApiKeyInput', + templateOptions: { + label: 'API key', + help: 'Alphanumeric only.', + required: true + }, + validators: { + apiKey: regexValidator(/^[a-zA-Z0-9]*$/, "API key must only contain numbers and digits", false) + } + }, + { + key: 'dereferer', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Dereferer', + help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.', + advanced: true + } + }, + { + key: 'verifySsl', + type: 'horizontalSwitch', + templateOptions: { + label: 'Verify SSL certificates', + help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.', + advanced: true + } + }, + { + key: 'verifySslDisabledFor', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Disable SSL for...', + help: 'Add hosts for which to disable SSL verification. Apply words with return key.', + advanced: true + } + }, + { + key: 'disableSslLocally', + type: 'horizontalSwitch', + templateOptions: { + type: 'text', + label: 'Disable SSL locally', + help: 'Disable SSL for local hosts.', + advanced: true + } + }, + { + key: 'sniDisabledFor', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Disable SNI', + help: 'Add a host if you get an "unrecognized_name" error. Apply words with return key. See wiki.', + advanced: true + } + }, + { + key: 'useCsrf', + type: 'horizontalSwitch', + templateOptions: { + label: 'Use CSRF protection', + help: 'Use CSRF protection.', + advanced: true + } + } + ] + }, - function apply() { - $scope.active = $scope.filterValue.min || $scope.filterValue.max; - $scope.$emit("filter", $scope.column, { - filterValue: $scope.filterValue, - filterType: "numberRange" - }, $scope.active) - } + { + wrapper: 'fieldset', + key: 'logging', + templateOptions: { + label: 'Logging', + tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.', + advanced: true + }, + fieldGroup: [ + { + key: 'logfilelevel', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'Logfile level', + options: [ + {name: 'Error', value: 'ERROR'}, + {name: 'Warning', value: 'WARN'}, + {name: 'Info', value: 'INFO'}, + {name: 'Debug', value: 'DEBUG'} + ], + help: 'Takes effect on next restart.' + } + }, + { + key: 'logMaxHistory', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Max log history', + help: 'How many daily log files will be kept.' + } + }, + { + key: 'consolelevel', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'Console log level', + options: [ + {name: 'Error', value: 'ERROR'}, + {name: 'Warning', value: 'WARN'}, + {name: 'Info', value: 'INFO'}, + {name: 'Debug', value: 'DEBUG'} + ], + help: 'Takes effect on next restart.' + } + }, + { + key: 'logGc', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Log GC', + help: 'Enable garbage collection logging. Only for debugging of memory issues.' + } + }, + { + key: 'logIpAddresses', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Log IP addresses' + } + }, + { + key: 'mapIpToHost', + type: 'horizontalSwitch', + hideExpression: '!model.logIpAddresses', + templateOptions: { + type: 'switch', + label: 'Map hosts', + help: 'Try to map logged IP addresses to host names.', + tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.' + } + }, + { + key: 'logUsername', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Log user names' + } + }, + { + key: 'markersToLog', + type: 'horizontalMultiselect', + hideExpression: 'model.consolelevel !== "DEBUG" && model.logfilelevel !== "DEBUG"', + templateOptions: { + label: 'Log markers', + help: 'Select certain sections for more output on debug level. Please enable only when asked for.', + options: [ + {label: 'API limits', id: 'LIMITS'}, + {label: 'Category mapping', id: 'CATEGORY_MAPPING'}, + {label: 'Config file handling', id: 'CONFIG_READ_WRITE'}, + {label: 'Custom mapping', id: 'CUSTOM_MAPPING'}, + {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'}, + {label: 'Duplicate detection', id: 'DUPLICATES'}, + {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'}, + {label: 'History cleanup', id: 'HISTORY_CLEANUP'}, + {label: 'HTTP', id: 'HTTP'}, + {label: 'HTTPS', id: 'HTTPS'}, + {label: 'HTTP Server', id: 'SERVER'}, + {label: 'Indexer scheduler', id: 'SCHEDULER'}, + {label: 'Notifications', id: 'NOTIFICATIONS'}, + {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'}, + {label: 'Performance', id: 'PERFORMANCE'}, + {label: 'Rejected results', id: 'RESULT_ACCEPTOR'}, + {label: 'Removed trailing words', id: 'TRAILING'}, + {label: 'URL calculation', id: 'URL_CALCULATION'}, + {label: 'User agent mapping', id: 'USER_AGENT'}, + {label: 'VIP expiry', id: 'VIP_EXPIRY'} + ], + buttonText: "None" + } + }, + { + key: 'historyUserInfoType', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'History user info', + options: [ + {name: 'IP and username', value: 'BOTH'}, + {name: 'IP address', value: 'IP'}, + {name: 'Username', value: 'USERNAME'}, + {name: 'None', value: 'NONE'} + ], + help: 'Only affects if value is displayed in the search/download history.', + hideExpression: '!model.keepHistory' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Backup', + advanced: true + }, + fieldGroup: [ + { + key: 'backupFolder', + type: 'horizontalInput', + templateOptions: { + label: 'Backup folder', + help: 'Either relative to the NZBHydra data folder or an absolute folder.' + } + }, + { + key: 'backupEveryXDays', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Backup every...', + addonRight: { + text: 'days' + } + } + }, + { + key: 'backupBeforeUpdate', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Backup before update' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: {label: 'Updates'}, + fieldGroup: [ + { + key: 'updateAutomatically', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Install updates automatically' + } + }, { + key: 'updateToPrereleases', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Install prereleases', + advanced: true + } + }, + { + key: 'deleteBackupsAfterWeeks', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Delete backups after...', + addonRight: { + text: 'weeks' + }, + advanced: true + } + }, + { + key: 'showUpdateBannerOnDocker', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Show update banner when managed externally', + advanced: true, + help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\'t let NZBHydra update itself).' + } + }, + { + key: 'showWhatsNewBanner', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Show info banner after automatic updates', + help: 'Please keep it enabled, I put some effort into the changelog ;-)', + advanced: true + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'History', + advanced: true + }, + fieldGroup: [ + { + key: 'keepHistory', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Keep history', + help: 'Controls search and download history.', + tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.' + } + }, + { + key: 'keepHistoryForWeeks', + type: 'horizontalInput', + hideExpression: '!model.keepHistory', + templateOptions: { + type: 'number', + label: 'Keep history for...', + addonRight: { + text: 'weeks' + }, + min: 1, + help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.' + } + }, + { + key: 'keepStatsForWeeks', + type: 'horizontalInput', + hideExpression: '!model.keepHistory', + templateOptions: { + type: 'number', + label: 'Keep stats for...', + addonRight: { + text: 'weeks' + }, + min: 1, + help: 'Only keep stats for a certain time. Will decrease database size.' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Database', + tooltip: 'You should not change these values unless you\'re either told to or really know what you\'re doing.', + advanced: true + }, + fieldGroup: [ + { + key: 'databaseCompactTime', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Database compact time', + addonRight: { + text: 'ms' + }, + min: 200, + help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.' + } + }, + { + key: 'databaseRetentionTime', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Database retention time', + addonRight: { + text: 'ms' + }, + help: 'How long the db should retain old, persisted data. See here.' + } + }, + { + key: 'databaseWriteDelay', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Database write delay', + addonRight: { + text: 'ms' + }, + help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.' + } + } - $scope.clear = function () { - $scope.filterValue = {min: undefined, max: undefined}; - $scope.active = false; - $scope.$emit("filter", $scope.column, { - filterValue: undefined, - filterType: "numberRange", - isBoolean: $scope.isBoolean - }, $scope.active) - }; - $scope.$on("clear", $scope.clear); + ] + }, + { + wrapper: 'fieldset', + templateOptions: {label: 'Other'}, + fieldGroup: [ + { + key: 'startupBrowser', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Open browser on startup' + } + }, + { + key: 'showNews', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Show news', + help: "Hydra will occasionally show news when opened. You can always find them in the system section", + advanced: true + } + }, + { + key: 'proxyImages', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Proxy images', + help: 'Download images from indexers and info providers (e.g. TMBD) and serve them via NZBHydra. Will only affect searches via UI, not API searches.' + } + }, + { + key: 'checkOpenPort', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Check for open port', + help: "Check if NZBHydra is reachable from the internet and not protected", + advanced: true + } + }, + { + key: 'xmx', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'JVM memory', + addonRight: { + text: 'MB' + }, + min: 128, + help: '256 should suffice except when working with big databases / many indexers. See wiki.', + advanced: true + } + } + ] - $scope.apply = function () { - apply(); - }; + } + ], - $scope.onKeypress = function (keyEvent) { - if (keyEvent.which === 13) { - apply(); - } - }; + searching: [ + { + wrapper: 'fieldset', + templateOptions: { + label: 'Indexer access', + tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.', + advanced: true + }, + fieldGroup: [ + { + key: 'timeout', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Timeout when accessing indexers', + help: 'Any web call to an indexer taking longer than this is aborted.', + min: 1, + addonRight: { + text: 'seconds' + } + } + }, + { + key: 'userAgent', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'User agent', + help: 'Used when accessing indexers.', + required: true, + tooltip: 'Some indexers don\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.', + } + }, + { + key: 'userAgents', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Map user agents', + help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.', + } + }, + { + key: 'ignoreLoadLimitingForInternalSearches', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Ignore load limiting internally', + help: 'When enabled load limiting defined for indexers will be ignored for internal searches.', + } + }, + { + key: 'ignoreTemporarilyDisabled', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Ignore temporary errors', + tooltip: "By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.", + } + } + ] + }, { + wrapper: 'fieldset', + templateOptions: { + label: 'Category handling', + tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).', + advanced: true + }, + fieldGroup: [ - DebugService.log("filter-number"); - } -} + { + key: 'transformNewznabCategories', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Transform newznab categories', + help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.' + } + }, + { + key: 'sendTorznabCategories', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Send categories to trackers', + help: 'If disabled no categories will be included in queries to torznab indexers (trackers).' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Media IDs / Query generation / Query processing', + tooltip: 'Raw search engines like Binsearch don\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\'s or show\'s title and generate a query, for example "showname s01e01". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.' + }, + fieldGroup: [ + { + key: 'alwaysConvertIds', + type: 'horizontalSelect', + templateOptions: { + label: 'Convert media IDs for...', + options: [ + {name: 'Internal searches', value: 'INTERNAL'}, + {name: 'API searches', value: 'API'}, + {name: 'All searches', value: 'BOTH'}, + {name: 'Never', value: 'NONE'} + ], + help: "When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).", + advanced: true + } + }, + { + key: 'generateQueries', + type: 'horizontalSelect', + templateOptions: { + label: 'Generate queries', + options: [ + {name: 'Internal searches', value: 'INTERNAL'}, + {name: 'API searches', value: 'API'}, + {name: 'All searches', value: 'BOTH'}, + {name: 'Never', value: 'NONE'} + ], + help: "Generate queries for indexers which do not support ID based searches." + } + }, + { + key: 'idFallbackToQueryGeneration', + type: 'horizontalSelect', + templateOptions: { + label: 'Fallback to generated queries', + options: [ + {name: 'Internal searches', value: 'INTERNAL'}, + {name: 'API searches', value: 'API'}, + {name: 'All searches', value: 'BOTH'}, + {name: 'Never', value: 'NONE'} + ], + help: "When no results were found for a query ID search again using a generated query (on indexer level)." + } + }, + { + key: 'language', + type: 'horizontalSelect', + templateOptions: { + type: 'text', + label: 'Language', + required: true, + help: 'Used for movie query generation and autocomplete only.', + options: [{"name": "Abkhaz", value: "ab"}, { + "name": "Afar", + value: "aa" + }, {"name": "Afrikaans", value: "af"}, {"name": "Akan", value: "ak"}, { + "name": "Albanian", + value: "sq" + }, {"name": "Amharic", value: "am"}, { + "name": "Arabic", + value: "ar" + }, {"name": "Aragonese", value: "an"}, {"name": "Armenian", value: "hy"}, { + "name": "Assamese", + value: "as" + }, {"name": "Avaric", value: "av"}, {"name": "Avestan", value: "ae"}, { + "name": "Aymara", + value: "ay" + }, {"name": "Azerbaijani", value: "az"}, { + "name": "Bambara", + value: "bm" + }, {"name": "Bashkir", value: "ba"}, { + "name": "Basque", + value: "eu" + }, {"name": "Belarusian", value: "be"}, {"name": "Bengali", value: "bn"}, { + "name": "Bihari", + value: "bh" + }, {"name": "Bislama", value: "bi"}, { + "name": "Bosnian", + value: "bs" + }, {"name": "Breton", value: "br"}, {"name": "Bulgarian", value: "bg"}, { + "name": "Burmese", + value: "my" + }, {"name": "Catalan", value: "ca"}, { + "name": "Chamorro", + value: "ch" + }, {"name": "Chechen", value: "ce"}, {"name": "Chichewa", value: "ny"}, { + "name": "Chinese", + value: "zh" + }, {"name": "Chuvash", value: "cv"}, { + "name": "Cornish", + value: "kw" + }, {"name": "Corsican", value: "co"}, {"name": "Cree", value: "cr"}, { + "name": "Croatian", + value: "hr" + }, {"name": "Czech", value: "cs"}, {"name": "Danish", value: "da"}, { + "name": "Divehi", + value: "dv" + }, {"name": "Dutch", value: "nl"}, { + "name": "Dzongkha", + value: "dz" + }, {"name": "English", value: "en"}, { + "name": "Esperanto", + value: "eo" + }, {"name": "Estonian", value: "et"}, {"name": "Ewe", value: "ee"}, { + "name": "Faroese", + value: "fo" + }, {"name": "Fijian", value: "fj"}, {"name": "Finnish", value: "fi"}, { + "name": "French", + value: "fr" + }, {"name": "Fula", value: "ff"}, { + "name": "Galician", + value: "gl" + }, {"name": "Georgian", value: "ka"}, {"name": "German", value: "de"}, { + "name": "Greek", + value: "el" + }, {"name": "Guaraní", value: "gn"}, { + "name": "Gujarati", + value: "gu" + }, {"name": "Haitian", value: "ht"}, {"name": "Hausa", value: "ha"}, { + "name": "Hebrew", + value: "he" + }, {"name": "Herero", value: "hz"}, { + "name": "Hindi", + value: "hi" + }, {"name": "Hiri Motu", value: "ho"}, { + "name": "Hungarian", + value: "hu" + }, {"name": "Interlingua", value: "ia"}, { + "name": "Indonesian", + value: "id" + }, {"name": "Interlingue", value: "ie"}, { + "name": "Irish", + value: "ga" + }, {"name": "Igbo", value: "ig"}, {"name": "Inupiaq", value: "ik"}, { + "name": "Ido", + value: "io" + }, {"name": "Icelandic", value: "is"}, { + "name": "Italian", + value: "it" + }, {"name": "Inuktitut", value: "iu"}, {"name": "Japanese", value: "ja"}, { + "name": "Javanese", + value: "jv" + }, {"name": "Kalaallisut", value: "kl"}, { + "name": "Kannada", + value: "kn" + }, {"name": "Kanuri", value: "kr"}, {"name": "Kashmiri", value: "ks"}, { + "name": "Kazakh", + value: "kk" + }, {"name": "Khmer", value: "km"}, { + "name": "Kikuyu", + value: "ki" + }, {"name": "Kinyarwanda", value: "rw"}, {"name": "Kyrgyz", value: "ky"}, { + "name": "Komi", + value: "kv" + }, {"name": "Kongo", value: "kg"}, {"name": "Korean", value: "ko"}, { + "name": "Kurdish", + value: "ku" + }, {"name": "Kwanyama", value: "kj"}, { + "name": "Latin", + value: "la" + }, {"name": "Luxembourgish", value: "lb"}, { + "name": "Ganda", + value: "lg" + }, {"name": "Limburgish", value: "li"}, {"name": "Lingala", value: "ln"}, { + "name": "Lao", + value: "lo" + }, {"name": "Lithuanian", value: "lt"}, { + "name": "Luba-Katanga", + value: "lu" + }, {"name": "Latvian", value: "lv"}, {"name": "Manx", value: "gv"}, { + "name": "Macedonian", + value: "mk" + }, {"name": "Malagasy", value: "mg"}, { + "name": "Malay", + value: "ms" + }, {"name": "Malayalam", value: "ml"}, {"name": "Maltese", value: "mt"}, { + "name": "Māori", + value: "mi" + }, {"name": "Marathi", value: "mr"}, { + "name": "Marshallese", + value: "mh" + }, {"name": "Mongolian", value: "mn"}, {"name": "Nauru", value: "na"}, { + "name": "Navajo", + value: "nv" + }, {"name": "Northern Ndebele", value: "nd"}, { + "name": "Nepali", + value: "ne" + }, {"name": "Ndonga", value: "ng"}, { + "name": "Norwegian Bokmål", + value: "nb" + }, {"name": "Norwegian Nynorsk", value: "nn"}, { + "name": "Norwegian", + value: "no" + }, {"name": "Nuosu", value: "ii"}, { + "name": "Southern Ndebele", + value: "nr" + }, {"name": "Occitan", value: "oc"}, { + "name": "Ojibwe", + value: "oj" + }, {"name": "Old Church Slavonic", value: "cu"}, {"name": "Oromo", value: "om"}, { + "name": "Oriya", + value: "or" + }, {"name": "Ossetian", value: "os"}, {"name": "Panjabi", value: "pa"}, { + "name": "Pāli", + value: "pi" + }, {"name": "Persian", value: "fa"}, { + "name": "Polish", + value: "pl" + }, {"name": "Pashto", value: "ps"}, { + "name": "Portuguese", + value: "pt" + }, {"name": "Quechua", value: "qu"}, {"name": "Romansh", value: "rm"}, { + "name": "Kirundi", + value: "rn" + }, {"name": "Romanian", value: "ro"}, { + "name": "Russian", + value: "ru" + }, {"name": "Sanskrit", value: "sa"}, {"name": "Sardinian", value: "sc"}, { + "name": "Sindhi", + value: "sd" + }, {"name": "Northern Sami", value: "se"}, { + "name": "Samoan", + value: "sm" + }, {"name": "Sango", value: "sg"}, {"name": "Serbian", value: "sr"}, { + "name": "Gaelic", + value: "gd" + }, {"name": "Shona", value: "sn"}, {"name": "Sinhala", value: "si"}, { + "name": "Slovak", + value: "sk" + }, {"name": "Slovene", value: "sl"}, { + "name": "Somali", + value: "so" + }, {"name": "Southern Sotho", value: "st"}, { + "name": "Spanish", + value: "es" + }, {"name": "Sundanese", value: "su"}, {"name": "Swahili", value: "sw"}, { + "name": "Swati", + value: "ss" + }, {"name": "Swedish", value: "sv"}, {"name": "Tamil", value: "ta"}, { + "name": "Telugu", + value: "te" + }, {"name": "Tajik", value: "tg"}, { + "name": "Thai", + value: "th" + }, {"name": "Tigrinya", value: "ti"}, { + "name": "Tibetan Standard", + value: "bo" + }, {"name": "Turkmen", value: "tk"}, {"name": "Tagalog", value: "tl"}, { + "name": "Tswana", + value: "tn" + }, {"name": "Tonga", value: "to"}, {"name": "Turkish", value: "tr"}, { + "name": "Tsonga", + value: "ts" + }, {"name": "Tatar", value: "tt"}, { + "name": "Twi", + value: "tw" + }, {"name": "Tahitian", value: "ty"}, { + "name": "Uyghur", + value: "ug" + }, {"name": "Ukrainian", value: "uk"}, {"name": "Urdu", value: "ur"}, { + "name": "Uzbek", + value: "uz" + }, {"name": "Venda", value: "ve"}, { + "name": "Vietnamese", + value: "vi" + }, {"name": "Volapük", value: "vo"}, {"name": "Walloon", value: "wa"}, { + "name": "Welsh", + value: "cy" + }, {"name": "Wolof", value: "wo"}, { + "name": "Western Frisian", + value: "fy" + }, {"name": "Xhosa", value: "xh"}, {"name": "Yiddish", value: "yi"}, { + "name": "Yoruba", + value: "yo" + }, {"name": "Zhuang", value: "za"}, {"name": "Zulu", value: "zu"}] + } + }, + { + key: 'replaceUmlauts', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Replace umlauts', + help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Result filters', + tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: "ea" matches "something.from.ea" but not "release.from.other". "web-dl" matches "title.web-dl" and "someweb-dl".' + }, + fieldGroup: [ + { + key: 'applyRestrictions', + type: 'horizontalSelect', + templateOptions: { + label: 'Apply word filters', + options: [ + {name: 'All searches', value: 'BOTH'}, + {name: 'Internal searches', value: 'INTERNAL'}, + {name: 'API searches', value: 'API'}, + {name: 'Never', value: 'NONE'} + ], + help: "For which type of search word/regex filters will be applied" + } + }, + { + key: 'forbiddenWords', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Forbidden words', + help: "Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.", + tooltip: 'One forbidden word in a result title dismisses the result.' + }, + hideExpression: function () { + return rootModel.searching.applyRestrictions === "NONE"; + } + }, + { + key: 'forbiddenRegex', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Forbidden regex', + help: 'Must not be present in a title (case is ignored).', + advanced: true + }, + hideExpression: function () { + return rootModel.searching.applyRestrictions === "NONE"; + } + }, + { + key: 'requiredWords', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Required words', + help: "Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.", + tooltip: 'If any of the required words is not found anywhere in a result title it\'s also dismissed.' + }, + hideExpression: function () { + return rootModel.searching.applyRestrictions === "NONE"; + } + }, + { + key: 'requiredRegex', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Required regex', + help: 'Must be present in a title (case is ignored).', + advanced: true + }, + hideExpression: function () { + return rootModel.searching.applyRestrictions === "NONE"; + } + }, + { + key: 'forbiddenGroups', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Forbidden groups', + help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.', + advanced: true + }, + hideExpression: function () { + return rootModel.searching.applyRestrictions === "NONE"; + } + }, + { + key: 'forbiddenPosters', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Forbidden posters', + help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.', + advanced: true + } + }, + { + key: 'languagesToKeep', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Languages to keep', + help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.' + } + }, + { + key: 'maxAge', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Maximum results age', + help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.', + addonRight: { + text: 'days' + } + } + }, + { + key: 'minSeeders', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Minimum # seeders', + help: 'Torznab results with fewer seeders will be ignored.' + } + }, + { + key: 'ignorePassworded', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Ignore passworded releases', + help: "Not all indexers provide this information", + tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\'re actually passworded.' + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Result processing' + }, + fieldGroup: [ + { + key: 'wrapApiErrors', + type: 'horizontalSwitch', + templateOptions: { + type: 'text', + label: 'Wrap API errors in empty results page', + help: 'When enabled accessing tools will think the search was completed successfully but without results.', + tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\'t return a result. That way Hydra won\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.', + advanced: true + } + }, + { + key: 'removeTrailing', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Remove trailing...', + help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards ("*"). Apply words with return key.', + tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.' + } + }, + { + key: 'useOriginalCategories', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Use original categories', + help: 'Enable to use the category descriptions provided by the indexer.', + tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.', + advanced: true + } + } + ] + }, + { + type: 'repeatSection', + key: 'customMappings', + model: rootModel.searching, + templateOptions: { + tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.', + btnText: 'Add new custom mapping', + altLegendText: 'Mapping', + headline: 'Custom mappings of queries, search titles and result titles', + advanced: true, + fields: [ + { + key: 'affectedValue', + type: 'horizontalSelect', + templateOptions: { + label: 'Affected value', + options: [ + {name: 'Query', value: 'QUERY'}, + {name: 'Search title', value: 'TITLE'}, + {name: 'Result title', value: 'RESULT_TITLE'}, + ], + required: true, + help: "Determines which value of the search request or result will be processed" + } + }, + { + key: 'searchType', + type: 'horizontalSelect', + hideExpression: 'model.affectedValue === "RESULT_TITLE"', + 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: 'matchAll', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Match whole string', + help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\'s only part of the affected value.' + } + }, + { + key: 'from', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Input pattern', + help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). 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', + required: true, + 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). Use <remove> to remove the match.' + } + }, + { + type: 'customMappingTest', + } + ], + defaultModel: { + searchType: null, + affectedValue: null, + matchAll: true, + from: null, + to: null + } + } + }, -angular - .module('nzbhydraApp').directive("columnSortable", columnSortable); -function columnSortable() { - controller.$inject = ["$scope"]; - return { - restrict: "E", - templateUrl: "static/html/dataTable/columnSortable.html", - transclude: true, - scope: { - sortMode: "<", //0: no sorting, 1: asc, 2: desc - column: "@", - reversed: "<", - startMode: "<" - }, - controller: controller - }; + { + wrapper: 'fieldset', + templateOptions: { + label: 'Result display' + }, + fieldGroup: [ + { + key: 'loadAllCachedOnInternal', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Display all retrieved results', + help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.', + advanced: true + } + }, + { + key: 'loadLimitInternal', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Display...', + addonRight: { + text: 'results per page' + }, + max: 500, + required: true, + help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.', + advanced: true + } + }, + { + key: 'coverSize', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Cover width', + addonRight: { + text: 'px' + }, + required: true, + help: 'Determines width of covers in search results (when enabled in display options).' + } + } + ] + }, { + wrapper: 'fieldset', + templateOptions: { + label: 'Quick filters' + }, + fieldGroup: [ + { + key: 'showQuickFilterButtons', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Show quick filters', + help: 'Show quick filter buttons for movie and TV results.' + } + }, + { + key: 'alwaysShowQuickFilterButtons', + type: 'horizontalSwitch', + hideExpression: '!model.showQuickFilterButtons', + templateOptions: { + type: 'switch', + label: 'Always show quick filters', + help: 'Show all quick filter buttons for all types of searches.', + advanced: true + } + }, + { + key: 'customQuickFilterButtons', + type: 'horizontalChips', + hideExpression: '!model.showQuickFilterButtons', + templateOptions: { + type: 'text', + label: 'Custom quick filters', + help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Surround with / to mark as a regex. Apply values with enter key.', + tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name "WEB" to be displayed that searches for "webdl" and "web-dl" in lowercase search results.', + advanced: true + } + }, + { + key: 'preselectQuickFilterButtons', + type: 'horizontalMultiselect', + hideExpression: '!model.showQuickFilterButtons', + templateOptions: { + label: 'Preselect quickfilters', + help: 'Choose which quickfilters will be selected by default.', + options: [ + {id: 'source|camts', label: 'CAM / TS'}, + {id: 'source|tv', label: 'TV'}, + {id: 'source|web', label: 'WEB'}, + {id: 'source|dvd', label: 'DVD'}, + {id: 'source|bluray', label: 'Blu-Ray'}, + {id: 'quality|q480p', label: '480p'}, + {id: 'quality|q720p', label: '720p'}, + {id: 'quality|q1080p', label: '1080p'}, + {id: 'quality|q2160p', label: '2160p'}, + {id: 'other|q3d', label: '3D'}, + {id: 'other|qx265', label: 'x265'}, + {id: 'other|qhevc', label: 'HEVC'}, + ], + optionsFunction: function (model) { + var customQuickFilters = []; + _.each(model.customQuickFilterButtons, function (entry) { + var split1 = entry.split("="); + var displayName = split1[0]; + customQuickFilters.push({id: "custom|" + displayName, label: displayName}) + }) + return customQuickFilters; + }, + tooltip: 'To select custom quickfilters you just entered please save the config first.', + buttonText: "None", + advanced: true + } + } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Duplicate detection', + tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.', + advanced: true + }, + fieldGroup: [ + { + key: 'duplicateSizeThresholdInPercent', + type: 'horizontalPercentInput', + templateOptions: { + type: 'text', + label: 'Duplicate size threshold', + required: true, + addonRight: { + text: '%' + } - function controller($scope) { - if (angular.isUndefined($scope.sortMode)) { - $scope.sortMode = 0; - } + } + }, + { + key: 'duplicateAgeThreshold', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Duplicate age threshold', + required: true, + addonRight: { + text: 'hours' + } + } + } - if (angular.isUndefined($scope.startMode)) { - $scope.startMode = 1; - } + ] + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Other', + advanced: true + }, + fieldGroup: [ + { + key: 'keepSearchResultsForDays', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Store results for ...', + addonRight: { + text: 'days' + }, + required: true, + tooltip: 'Found results are stored in the database for this long until they\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).' + } + }, { + key: 'historyForSearching', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Recet searches in search bar', + required: true, + tooltip: 'The number of recent searches shown in the search bar dropdown (the icon).' + } + }, + { + key: 'globalCacheTimeMinutes', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Results cache time', + help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.', + addonRight: { + text: 'minutes' + } + } + } + ] + } + ], - $scope.sortModel = { - sortMode: $scope.sortMode, - column: $scope.column, - reversed: $scope.reversed, - startMode: $scope.startMode, - active: false - }; + categoriesConfig: [ + { + key: 'enableCategorySizes', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Category sizes', + help: "Preset min and max sizes depending on the selected category", + tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.' + } + }, + { + key: 'defaultCategory', + type: 'horizontalSelect', + templateOptions: { + label: 'Default category', + options: [], + help: "Set a default category. Reload page to set a category you just added." + }, + controller: function ($scope) { + var options = []; + options.push({name: 'All', value: 'All'}); + _.each($scope.model.categories, function (cat) { + options.push({name: cat.name, value: cat.name}); + }); + $scope.to.options = options; + } + }, + { + type: 'help', + templateOptions: { + type: 'help', + lines: [ + "The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.", + "Restrictions will taken from a result's category, not the search request category which may not always be the same." + ], + marginTop: '50px', + advanced: true + } + }, + { + type: 'repeatSection', + key: 'categories', + model: rootModel.categoriesConfig, + templateOptions: { + btnText: 'Add new category', + headline: 'Categories', + advanced: true, + fields: [ + { + key: 'name', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Name', + help: 'Renaming categories might cause problems with repeating searches from the history.', + required: true + } + }, + { + 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 how indexers will be searched and if autocompletion is available in the GUI" + } + }, + { + key: 'subtype', + type: 'horizontalSelect', + templateOptions: { + label: 'Sub type', + options: [ + {name: 'Anime', value: 'ANIME'}, + {name: 'Audiobook', value: 'AUDIOBOOK'}, + {name: 'Comic', value: 'COMIC'}, + {name: 'Ebook', value: 'EBOOK'}, + {name: 'None', value: 'NONE'} + ], + help: "Special search type. Used for indexer specific mappings between categories and newznab IDs" + } + }, + { + key: 'applyRestrictionsType', + type: 'horizontalSelect', + templateOptions: { + label: 'Apply restrictions', + options: [ + {name: 'All searches', value: 'BOTH'}, + {name: 'Internal searches', value: 'INTERNAL'}, + {name: 'API searches', value: 'API'}, + {name: 'Never', value: 'NONE'} + ], + help: "For which type of search word restrictions will be applied" + } + }, + { + key: 'requiredWords', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Required words', + help: "Must *all* be present in a title which is converted to lowercase before. Apply words with return key." + } + }, + { + key: 'requiredRegex', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Required regex', + help: 'Must be present in a title (case is ignored).' + } + }, + { + key: 'forbiddenWords', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Forbidden words', + help: "None may be present in a title which is converted to lowercase before. Apply words with return key." + } + }, + { + key: 'forbiddenRegex', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Forbidden regex', + help: 'Must not be present in a title (case is ignored).' + } + }, + { + wrapper: 'settingWrapper', + templateOptions: { + label: 'Size preset', + help: "Will set these values on the search page" + }, + fieldGroup: [ + { + key: 'minSizePreset', + type: 'duoSetting', + templateOptions: { + addonRight: { + text: 'MB' + } - $scope.$on("newSortColumn", function (event, column, sortMode) { - $scope.sortModel.active = column === $scope.sortModel.column; - if (column !== $scope.sortModel.column) { - $scope.sortModel.sortMode = 0; - } else { - $scope.sortModel.sortMode = sortMode; - } - }); + } + }, + { + type: 'duolabel' + }, + { + key: 'maxSizePreset', + type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}} + } + ] + }, + { + key: 'applySizeLimitsToApi', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Limit API results size', + help: "Enable to apply the size preset to API results from this category" + } + }, + { + key: 'newznabCategories', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Newznab categories', + help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.', + tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of "Movies HD" the settings for that category are used. Otherwise it checks if it matches the "Movies" category and, if yes, uses that one. If that one doesn\'t match no category settings are used.

          ' + + 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using "&" to require multiple numbers to be present in a result. For example "2010&11000" would require a search result to contain both 2010 and 11000 for that category to match.

          ' + + 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.' + } + }, + { + key: 'ignoreResultsFrom', + type: 'horizontalSelect', + templateOptions: { + label: 'Ignore results', + options: [ + {name: 'For all searches', value: 'BOTH'}, + {name: 'For internal searches', value: 'INTERNAL'}, + {name: 'For API searches', value: 'API'}, + {name: 'Never', value: 'NONE'} + ], + help: "Ignore results from this category", + tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select "Internal" or "Always" this category will also not be selectable on the search page.' + } + } - $scope.sort = function () { - if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) { - $scope.sortModel.sortMode = $scope.sortModel.startMode; - } else if ($scope.sortModel.sortMode === 1) { - $scope.sortModel.sortMode = 2; - } else { - $scope.sortModel.sortMode = 1; - } - $scope.$emit("sort", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed) - }; + ], + defaultModel: { + name: null, + applySizeLimitsToApi: false, + applyRestrictionsType: "NONE", + forbiddenRegex: null, + forbiddenWords: [], + ignoreResultsFrom: "NONE", + mayBeSelected: true, + maxSizePreset: null, + minSizePreset: null, + newznabCategories: [], + preselect: true, + requiredRegex: null, + requiredWords: [], + searchType: "SEARCH", + subtype: "NONE" + } + } + } + ], + downloading: [ + { + wrapper: 'fieldset', + templateOptions: { + label: 'General', + tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.' + }, + fieldGroup: [ + { + key: 'saveTorrentsTo', + type: 'fileInput', + templateOptions: { + label: 'Torrent black hole', + help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.', + type: "folder" + } + }, + { + key: 'saveNzbsTo', + type: 'fileInput', + templateOptions: { + label: 'NZB black hole', + help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.', + type: "folder" + } + }, + { + key: 'nzbAccessType', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'NZB access type', + options: [ + {name: 'Proxy NZBs from indexer', value: 'PROXY'}, + {name: 'Redirect to the indexer', value: 'REDIRECT'} + ], + help: "How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..", + tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).', + advanced: true - } -} + } + }, + { + key: 'externalUrl', + type: 'horizontalInput', + hideExpression: function ($viewValue, $modelValue, scope) { + return !_.any(scope.model.downloaders, function (downloader) { + return downloader.nzbAddingType === "SEND_LINK"; + }); + }, + templateOptions: { + label: 'External URL', + help: 'Used for links when sending links to the downloader.', + tooltip: 'When using "Add links" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\'s not accessible by the downloader (e.g. when it\'s inside a docker container). Set the URL for NZBHydra that\'s accessible by the downloader here and it will be used instead. ', + advanced: true + } + }, -angular - .module('nzbhydraApp') - .directive('connectionTest', connectionTest); + { + key: 'fallbackForFailed', + type: 'horizontalSelect', + hideExpression: 'model.nzbAccessType === "REDIRECT"', + templateOptions: { + label: 'Fallback for failed downloads', + options: [ + {name: 'GUI downloads', value: 'INTERNAL'}, + {name: 'API downloads', value: 'API'}, + {name: 'All downloads', value: 'BOTH'}, + {name: 'Never', value: 'NONE'} + ], + help: "Fallback to similar results when a download fails. Only available when proxying NZBs (see above).", + tooltip: "When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search." + } + }, + { + key: 'sendMagnetLinks', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Send magnet links', + help: "Enable to send magnet links to the associated program on the server machine. Won't work with docker" + } + }, + { + key: 'updateStatuses', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Update statuses', + help: "Query your downloader for status updates of downloads", + advanced: true + } + }, + { + key: 'showDownloaderStatus', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Show downloader footer', + help: "Show footer with downloader status", + advanced: true + } + }, + { + key: 'primaryDownloader', + type: 'horizontalSelect', + hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus', + templateOptions: { + label: 'Primary downloader', + options: [], + help: "This downloader's state will be shown in the footer.", + tooltip: "To select a downloader you just added please save the config first.", + optionsFunction: function (model) { + var downloaders = []; + _.each(model.downloaders, function (downloader) { + downloaders.push({name: downloader.name, value: downloader.name}) + }) + return downloaders; + }, + optionsFunctionAfter: function (model) { + if (!model.primaryDownloader) { + model.primaryDownloader = model.downloaders[0].name; + } + } + } + }, + ] + }, + { + wrapper: 'fieldset', + key: 'downloaders', + templateOptions: {label: 'Downloaders'}, + fieldGroup: [ + { + type: "downloaderConfig", + data: {} + } + ] + } + ], -function connectionTest() { - controller.$inject = ["$scope"]; - return { - templateUrl: 'static/html/directives/connection-test.html', - require: ['^type', '^data'], - scope: { - type: "=", - id: "=", - data: "=", - downloader: "=" - }, - controller: controller - }; + indexers: [ + { + type: "indexers", + data: {} + }, + { + type: 'recheckAllCaps' + } + ], + auth: [ + { + wrapper: 'fieldset', + templateOptions: { + label: 'Main', - function controller($scope) { - $scope.message = ""; + }, + fieldGroup: [ + { + key: 'authType', + type: 'horizontalSelect', + templateOptions: { + label: 'Auth type', + options: [ + {name: 'None', value: 'NONE'}, + {name: 'HTTP Basic auth', value: 'BASIC'}, + {name: 'Login form', value: 'FORM'} + ], + tooltip: '
            ' + + '
          • With auth type "None" all areas are unrestricted.
          • ' + + '
          • With auth type "Form" the basic page is loaded and login is done via a form.
          • ' + + '
          • With auth type "Basic" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
          • ' + + '
          ' + } + }, + { + key: 'authHeader', + type: 'horizontalInput', + templateOptions: { + type: 'string', + label: 'Auth header', + help: 'Name of header that provides the username in requests from secure sources.', + advanced: true + }, + hideExpression: function () { + return rootModel.auth.authType === "NONE"; + } + }, + { + key: 'authHeaderIpRanges', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Secure IP ranges', + help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like "192.168.0.1-192.168.0.100" or single IP addresses like "127.0.0.1".', + advanced: true + }, + hideExpression: function () { + return rootModel.auth.authType === "NONE" || _.isNullOrEmpty(rootModel.auth.authHeader); + } + }, + { + key: 'rememberUsers', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Remember users', + help: 'Remember users with cookie for 14 days.' + }, + hideExpression: function () { + return rootModel.auth.authType === "NONE"; + } + }, + { + key: 'rememberMeValidityDays', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Cookie expiry', + help: 'How long users are remembered.', + addonRight: { + text: 'days' + }, + advanced: true + } + } + ] + }, - var testButton = "#button-test-connection"; - var testMessage = "#message-test-connection"; + { + wrapper: 'fieldset', + templateOptions: { + label: 'Restrictions', + tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\'t to allow anonymous users to do anything just leave everything selected.
          You can decide for every user if he is allowed to:
          ' + + '
            \n' + + '
          • view the search page at all
          • \n' + + '
          • view the stats
          • \n' + + '
          • access the admin area (config and control)
          • \n' + + '
          • view links for downloading NZBs and see their details
          • \n' + + '
          • may select which indexers are used for search.
          • \n' + + '
          ' + }, + hideExpression: function () { + return rootModel.auth.authType === "NONE"; + }, + fieldGroup: [ + { + key: 'restrictSearch', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Restrict searching', + help: 'Restrict access to searching.' + } + }, + { + key: 'restrictStats', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Restrict stats', + help: 'Restrict access to stats.' + } + }, + { + key: 'restrictAdmin', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Restrict admin', + help: 'Restrict access to admin functions.' + } + }, + { + key: 'restrictDetailsDl', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Restrict NZB details & DL', + help: 'Restrict NZB details, comments and download links.' + } + }, + { + key: 'restrictIndexerSelection', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Restrict indexer selection box', + help: 'Restrict visibility of indexer selection box in search. Affects only GUI.' + } + }, + { + key: 'allowApiStats', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Allow stats access', + help: 'Allow access to stats via external API.' + } + } + ] + }, + + { + type: 'repeatSection', + key: 'users', + model: rootModel.auth, + hideExpression: function () { + return rootModel.auth.authType === "NONE"; + }, + templateOptions: { + btnText: 'Add new user', + altLegendText: 'Authless', + headline: 'Users', + fields: [ + { + key: 'username', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Username', + required: true + } + }, + { + key: 'password', + type: 'passwordSwitch', + templateOptions: { + type: 'password', + label: 'Password', + required: true + } + }, + { + key: 'maySeeAdmin', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'May see admin area' + } + }, + { + key: 'maySeeStats', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'May see stats' + }, + hideExpression: 'model.maySeeAdmin' + }, + { + key: 'maySeeDetailsDl', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'May see NZB details & DL links' + }, + hideExpression: 'model.maySeeAdmin' + }, + { + key: 'showIndexerSelection', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'May see indexer selection box' + }, + hideExpression: 'model.maySeeAdmin' + } + ], + defaultModel: { + username: null, + password: null, + token: null, + maySeeStats: true, + maySeeAdmin: true, + maySeeDetailsDl: true, + showIndexerSelection: true + } + } + } + ], + notificationConfig: [ + { + type: 'help', + templateOptions: { + type: 'help', + lines: [ + "NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.", + 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.', + "NZBHydra will also show notifications on the GUI if enabled.", + "Only URLs in the form of the http://../notify/ form will work. Each notification requires a non-null value for URL to be enabled, but always uses the Main URL." + ] + } + }, + { + wrapper: 'fieldset', + templateOptions: { + label: 'Main' + }, + fieldGroup: [ - function showSuccess() { - angular.element(testButton).removeClass("btn-default"); - angular.element(testButton).removeClass("btn-danger"); - angular.element(testButton).addClass("btn-success"); - } + { + key: 'appriseType', + type: 'horizontalSelect', + templateOptions: { + type: 'select', + label: 'Apprise type', + options: [ + {name: 'None', value: 'NONE'}, + {name: 'API', value: 'API'}, + {name: 'CLI', value: 'CLI'} + ] + } + }, + { + key: 'appriseApiUrl', + type: 'horizontalInput', + templateOptions: { + type: 'string', + label: 'Apprise API URL', + help: 'URL of Apprise API to send notifications to.' + }, + hideExpression: 'model.appriseType !== "API"' + }, + { + key: 'appriseCliPath', + type: 'fileInput', + templateOptions: { + type: 'file', + label: 'Apprise runnable', + help: 'Full path of of Apprise runnable to execute.' + }, + hideExpression: 'model.appriseType !== "CLI"' + }, + { + key: 'displayNotifications', + type: 'horizontalSwitch', + templateOptions: { + type: 'switch', + label: 'Display notifications', + help: 'If enabled notifications will be shown on the GUI.' + } + }, + { + key: 'displayNotificationsMax', + type: 'horizontalInput', + templateOptions: { + type: 'number', + label: 'Show max notifications', + help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.' + }, + hideExpression: '!model.displayNotifications' + }, + { + key: 'filterOuts', + type: 'horizontalChips', + templateOptions: { + type: 'text', + label: 'Hide if message contains...', + help: 'Apply values with return key. Surround with "/" for regex (e.g. /contains[0-9]This/). Case insensitive.', - function showError() { - angular.element(testButton).removeClass("btn-default"); - angular.element(testButton).removeClass("btn-success"); - angular.element(testButton).addClass("btn-danger"); - } + }, + hideExpression: '!model.displayNotifications' + } + ] + }, - $scope.testConnection = function () { - angular.element(testButton).addClass("glyphicon-refresh-animate"); - var myInjector = angular.injector(["ng"]); - var $http = myInjector.get("$http"); - var url; - var params; - if ($scope.type === "downloader") { - url = "internalapi/test_downloader"; - params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password}; - if ($scope.downloader === "SABNZBD") { - params.apiKey = $scope.data.apiKey; - params.url = $scope.data.url; - } else { - params.host = $scope.data.host; - params.port = $scope.data.port; - params.ssl = $scope.data.ssl; - } - } else if ($scope.data.type === "newznab") { - url = "internalapi/test_newznab"; - params = {host: $scope.data.host, apiKey: $scope.data.apiKey}; - if (angular.isDefined($scope.data.username)) { - params["username"] = $scope.data.username; - params["password"] = $scope.data.password; - } - } - $http.get(url, {params: params}).then(function (result) { - //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click - if (result.successful) { - angular.element(testMessage).text(""); - showSuccess(); - } else { - angular.element(testMessage).text(result.message); - showError(); - } + { + type: 'notificationSection', + key: 'entries', + model: rootModel.notificationConfig, + templateOptions: { + btnText: 'Add new notification', + altLegendText: 'Notification', + headline: 'Notifications', + fields: [ + { + key: 'appriseUrls', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'URLs', + help: 'One or more URLs identifying where the notification should be sent to, comma-separated.' + } + }, + { + key: 'titleTemplate', + type: 'horizontalInput', + templateOptions: { + type: 'text', + label: 'Title template' + }, + controller: notificationTemplateHelpController + }, + { + key: 'bodyTemplate', + type: 'horizontalTextArea', + templateOptions: { + type: 'text', + label: 'Body template', + required: true + }, + controller: notificationTemplateHelpController + }, + { + key: 'messageType', + type: 'horizontalSelect', + templateOptions: { + label: 'Message type', + options: [ + {name: 'Info', value: 'INFO'}, + {name: 'Success', value: 'SUCCESS'}, + {name: 'Warning', value: 'WARNING'}, + {name: 'Failure', value: 'FAILURE'} + ], + help: "Select the message type to use." + } + }, + { + key: 'bodyTemplate', + type: 'horizontalTestNotification' + } - }, function () { - angular.element(testMessage).text(result.message); - showError(); + ], + defaultModel: { + eventType: null, + appriseUrls: null, + titleTemplate: null, + bodyTemplate: null, + messageType: 'WARNING' + } + } } - ).finally(function () { - angular.element(testButton).removeClass("glyphicon-refresh-animate"); - }) + ] + } + function notificationTemplateHelpController($scope, NotificationService) { + $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType); + $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType); + } } } - -//Taken from https://github.com/IamAdamJowett/angular-click-outside - -clickOutside.$inject = ["$document", "$parse", "$timeout"]; -function childOf(/*child node*/c, /*parent node*/p) { //returns boolean - while ((c = c.parentNode) && c !== p) ; - return !!c; +function handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) { + var message; + var yesText; + if (data.checked) { + message = "The connection to the " + whatFailed + " failed: " + data.message + "
          Do you want to add it anyway?"; + yesText = "I know what I'm doing"; + } else { + message = "The connection to the " + whatFailed + " could not be tested, sorry. Please check the log."; + yesText = "I'll risk it"; + } + ModalService.open("Connection check failed", message, { + yes: { + onYes: function () { + deferred.resolve(); + }, + text: yesText + }, + no: { + onNo: function () { + model.enabled = false; + deferred.resolve(); + }, + text: "Add it, but disabled" + }, + cancel: { + onCancel: function () { + deferred.reject(); + }, + text: "Aahh, let me try again" + } + }); } -angular - .module('nzbhydraApp').directive("clickOutside", clickOutside); - -/** - * @ngdoc directive - * @name angular-click-outside.directive:clickOutside - * @description Directive to add click outside capabilities to DOM elements - * @requires $document - * @requires $parse - * @requires $timeout - **/ -function clickOutside($document, $parse, $timeout) { - return { - restrict: 'A', - link: function ($scope, elem, attr) { - - // postpone linking to next digest to allow for unique id generation - $timeout(function () { - var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [], - fn; - - function eventHandler(e) { - var i, - element, - r, - id, - classNames, - l; - - // check if our element already hidden and abort if so - if (angular.element(elem).hasClass("ng-hide")) { - return; - } - - // if there is no click target, no point going on - if (!e || !e.target) { - return; - } - if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) { - return; - } - var isChild = childOf(e.target, elem.context); - if (isChild) { - return; - } - // loop through the available elements, looking for classes in the class list that might match and so will eat - for (element = e.target; element; element = element.parentNode) { - // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru) - if (element === elem[0]) { - return; - } +ConfigController.$inject = ["$scope", "$http", "activeTab", "ConfigService", "config", "DownloaderCategoriesService", "ConfigFields", "ConfigModel", "ModalService", "RestartService", "localStorageService", "$state", "growl", "$window"];angular + .module('nzbhydraApp') + .factory('ConfigModel', function () { + return {}; + }); - // now we have done the initial checks, start gathering id's and classes - id = element.id, - classNames = element.className, - l = classList.length; +angular + .module('nzbhydraApp') + .factory('ConfigWatcher', function () { + var $scope; - // Unwrap SVGAnimatedString classes - if (classNames && classNames.baseVal !== undefined) { - classNames = classNames.baseVal; - } + return { + watch: watch + }; - // if there are no class names on the element clicked, skip the check - if (classNames || id) { + function watch(scope) { + $scope = scope; + $scope.$watchGroup(["config.main.host"], function () { + }, true); + } + }); - // loop through the elements id's and classnames looking for exceptions - for (i = 0; i < l; i++) { - //prepare regex for class word matching - r = new RegExp('\\b' + classList[i] + '\\b'); - // check for exact matches on id's or classes, but only if they exist in the first place - if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) { - // now let's exit out as it is an element that has been defined as being ignored for clicking outside - return; - } - } - } - } +angular + .module('nzbhydraApp') + .controller('ConfigController', ConfigController); - // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute - $timeout(function () { - fn = $parse(attr['clickOutside']); - fn($scope, {event: e}); - }); - } +function ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) { + $scope.config = config; + $scope.submit = submit; + $scope.activeTab = activeTab; - // if the devices has a touchscreen, listen for this event - if (_hasTouch()) { - $document.on('touchstart', eventHandler); - } + $scope.restartRequired = false; + $scope.ignoreSaveNeeded = false; + console.log(localStorageService.get("showAdvanced")); + if (localStorageService.get("showAdvanced") === null) { + $scope.showAdvanced = false; + localStorageService.set("showAdvanced", false); + } else { + $scope.showAdvanced = localStorageService.get("showAdvanced"); + } - // still listen for the click event even if there is touch to cater for touchscreen laptops - $document.on('click', eventHandler); - // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around - $scope.$on('$destroy', function () { - if (_hasTouch()) { - $document.off('touchstart', eventHandler); - } + $scope.toggleShowAdvanced = function () { + $scope.showAdvanced = !$scope.showAdvanced; + var wasDirty = $scope.form.$dirty === true; - $document.off('click', eventHandler); - }); + $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true; + //Also save in main tab where it will be stored to file + $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true; + $scope.form.$dirty = wasDirty; + localStorageService.set("showAdvanced", $scope.showAdvanced); + } - /** - * @description Private function to attempt to figure out if we are on a touch device - * @private - **/ - function _hasTouch() { - // works on most browsers, IE10/11 and Surface - return 'ontouchstart' in window || navigator.maxTouchPoints; + function updateAndAskForRestartIfNecessary(responseData) { + if (angular.isUndefined($scope.form)) { + console.error("Unable to determine if a restart is necessary"); + return; + } + + $scope.form.$setPristine(); + DownloaderCategoriesService.invalidate(); + if ($scope.restartRequired) { + ModalService.open("Restart required", "The changes you have made may require a restart to be effective.
          Do you want to restart now?", { + yes: { + onYes: function () { + RestartService.restart(); + } + }, + no: { + onNo: function ($uibModalInstance) { + //Needs to be clicked twice for some reason + $scope.restartRequired = false; + $uibModalInstance.dismiss(); + $uibModalInstance.dismiss(); + $scope.config = responseData.newConfig; + $window.location.reload(); + } } }); + } else { + $scope.config = responseData.newConfig; + $window.location.reload(); } - }; -} - -angular - .module('nzbhydraApp') - .directive('cfgFormEntry', cfgFormEntry); - -function cfgFormEntry() { - return { - templateUrl: 'static/html/directives/cfg-form-entry.html', - require: ["^title", "^cfg"], - scope: { - title: "@", - cfg: "=", - help: "@", - type: "@?", - options: "=?" - }, - controller: ["$scope", "$element", "$attrs", function ($scope, $element, $attrs) { - $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text'; - $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : []; - }] - }; -} -angular - .module('nzbhydraApp') - .directive('hydrabackup', hydrabackup); + } -function hydrabackup() { - controller.$inject = ["$scope", "BackupService", "Upload", "FileDownloadService", "$http", "RequestsErrorHandler", "growl", "RestartService"]; - return { - templateUrl: 'static/html/directives/backup.html', - controller: controller - }; + function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) { + if (angular.isUndefined(ignoreWarnings)) { + ignoreWarnings = localStorageService.get("ignoreWarnings") !== null ? localStorageService.get("ignoreWarnings") : false; + } + //Communication with server was successful but there might be validation errors and/or warnings + var warningMessages = response.data.warningMessages; + var errorMessages = response.data.errorMessages; + $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false); + var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings); - function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) { - $scope.refreshBackupList = function () { - BackupService.getBackupsList().then(function (backups) { - $scope.backups = backups; + function extendMessageWithList(message, messages) { + _.forEach(messages, function (x) { + message += "
        • " + x + "
        • "; }); - }; - - $scope.refreshBackupList(); - - $scope.uploadActive = false; + message += "
        "; + return message; + } + if (showMessage) { + var options; + var message; + var title; + if (errorMessages.length > 0) { //Actual errors which cannot be ignored + title = "Config validation failed"; + message = 'The following errors have been found in your config. They need to be fixed.
          '; + message = extendMessageWithList(message, response.data.errorMessages); + if (warningMessages.length > 0) { + message += '
          The following warnings were found. You can ignore them if you wish.
            '; + message = extendMessageWithList(message, response.data.warningMessages); + } + options = { + yes: { + onYes: function () { + }, + text: "OK" + } + }; + } else if (warningMessages.length > 0) { + title = "Config validation warnings"; + message = '
            The following warnings have been found. You can ignore them if you wish. The config was already saved.
              '; + message = extendMessageWithList(message, response.data.warningMessages); + options = { + // cancel: { + // onCancel: function () { + // $scope.form.$setPristine(); + // localStorageService.set("ignoreWarnings", true); + // ConfigService.set($scope.config, true).then(function (response) { + // handleConfigSetResponse(response, true, $scope.restartRequired); + // updateAndAskForRestartIfNecessary(response.data); + // }, function (response) { + // //Actual error while setting or validating config + // growl.error(response.data); + // }); + // }, + // text: "OK, don't show warnings again" + // }, + yes: { + onYes: function () { + handleConfigSetResponse(response, true, $scope.restartRequired); + updateAndAskForRestartIfNecessary(response.data); + }, + text: "OK" + } + }; + } + ModalService.open(title, message, options, "md", "left"); + } else { + updateAndAskForRestartIfNecessary(response.data); + } + } - $scope.createBackupFile = function () { - $http.get("internalapi/backup/backuponly", {params: {dontdownload: true}}).then(function () { - $scope.refreshBackupList(); - }); - }; - $scope.createAndDownloadBackupFile = function () { - FileDownloadService.downloadFile("internalapi/backup/backup", "nzbhydra-backup-" + moment().format("YYYY-MM-DD-HH-mm") + ".zip", "GET").then(function () { - $scope.refreshBackupList(); + function submit() { + if ($scope.form.$valid && !$scope.myShowError) { + ConfigService.set($scope.config, true).then(function (response) { + handleConfigSetResponse(response); + }, function (response) { + //Actual error while setting or validating config + growl.error(response.data); }); - }; - - $scope.uploadBackupFile = function (file, errFiles) { - RequestsErrorHandler.specificallyHandled(function () { - $scope.file = file; - $scope.errFile = errFiles && errFiles[0]; - if (file) { - $scope.uploadActive = true; - file.upload = Upload.upload({ - url: 'internalapi/backup/restorefile', - file: file - }); + } else { + growl.error("Config invalid. Please check your settings."); - file.upload.then(function (response) { - if (response.data.successful) { - $scope.uploadActive = false; - RestartService.startCountdown("Upload successful. Restarting for wrapper to restore data."); - } else { - file.progress = 0; - growl.error(response.data.message) + //Ridiculously hacky way to make the error messages appear + try { + if (angular.isDefined(form.$error.required)) { + _.each(form.$error.required, function (item) { + if (angular.isDefined(item.$error.required)) { + _.each(item.$error.required, function (item2) { + item2.$setTouched(); + }); } - - }, function (response) { - growl.error(response.data.message) - }, function (evt) { - file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total)); - file.loaded = Math.floor(evt.loaded / 1024); - file.total = Math.floor(evt.total / 1024); }); } - }); - }; + angular.forEach($scope.form.$error.required, function (field) { + field.$setTouched(); + }); + } catch (err) { + // + } - $scope.restoreFromFile = function (filename) { - BackupService.restoreFromFile(filename).then(function () { - RestartService.startCountdown("Extraction of backup successful. Restarting for wrapper to restore data."); - }, - function (response) { - growl.error(response.data); - }) } - } -} - + ConfigModel = config; -addableNzbs.$inject = ["DebugService"];angular - .module('nzbhydraApp') - .directive('addableNzbs', addableNzbs); + $scope.fields = ConfigFields.getFields($scope.config); -function addableNzbs(DebugService) { - controller.$inject = ["$scope", "NzbDownloadService"]; - return { - templateUrl: 'static/html/directives/addable-nzbs.html', - require: [], - scope: { - searchresult: "<", - alwaysAsk: "<" + $scope.allTabs = [ + { + active: false, + state: 'root.config.main', + name: 'Main', + model: ConfigModel.main, + fields: $scope.fields.main }, - controller: controller + { + active: false, + state: 'root.config.auth', + name: 'Authorization', + model: ConfigModel.auth, + fields: $scope.fields.auth, + options: {} + }, + { + active: false, + state: 'root.config.searching', + name: 'Searching', + model: ConfigModel.searching, + fields: $scope.fields.searching, + options: {} + }, + { + active: false, + state: 'root.config.categories', + name: 'Categories', + model: ConfigModel.categoriesConfig, + fields: $scope.fields.categoriesConfig, + options: {} + }, + { + active: false, + state: 'root.config.downloading', + name: 'Downloading', + model: ConfigModel.downloading, + fields: $scope.fields.downloading, + options: {} + }, + { + active: false, + state: 'root.config.indexers', + name: 'Indexers', + model: ConfigModel.indexers, + fields: $scope.fields.indexers, + options: {} + }, + { + active: false, + state: 'root.config.notifications', + name: 'Notifications', + model: ConfigModel.notificationConfig, + fields: $scope.fields.notificationConfig, + options: {} + } + ]; + + //Copy showAdvanced setting over from main tab's setting + _.each($scope.allTabs, function (tab) { + tab.model.showAdvanced = $scope.showAdvanced === true; + }) + + $scope.isSavingNeeded = function () { + return $scope.form.$dirty && $scope.form.$valid && !$scope.ignoreSaveNeeded; }; - function controller($scope, NzbDownloadService) { - $scope.alwaysAsk = $scope.alwaysAsk === "true"; - $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) { - if ($scope.searchresult.downloadType !== "NZB") { - return downloader.downloadType === $scope.searchresult.downloadType - } - return true; - }); - } -} + $scope.goToConfigState = function (index) { + $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true}); + }; + $scope.apiHelp = function () { -addableNzb.$inject = ["DebugService"];angular - .module('nzbhydraApp') - .directive('addableNzb', addableNzb); + if ($scope.isSavingNeeded()) { + growl.info("Please save first"); + return; + } + var apiHelp = ConfigService.apiHelp().then(function (data) { -function addableNzb(DebugService) { - controller.$inject = ["$scope", "NzbDownloadService", "growl"]; - return { - templateUrl: 'static/html/directives/addable-nzb.html', - scope: { - searchresult: "=", - downloader: "<", - alwaysAsk: "<" - }, - controller: controller + var html = '' + + '' + + '' + + '' + + '
              Newznab API endpoint:%newznab%
              Torznab API endpoint:%torznab%
              API key:%apikey%
              '; + //Torznab API endpoint: %torznab%
              API key: %apikey% + html = html.replace("%newznab%", data.newznabApi); + html = html.replace("%torznab%", data.torznabApi); + html = html.replace("%apikey%", data.apiKey); + ModalService.open("API infos", html, {}, "md"); + }); }; - function controller($scope, NzbDownloadService, growl) { - if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) { - $scope.cssClass = "fa fa-" + $scope.downloader.iconCssClass.replace("fa-", "").replace("fa ", ""); - } else { - $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd" : "nzbget"; + $scope.configureIn = function (externalTool) { + + if ($scope.isSavingNeeded()) { + growl.info("Please save first"); + return; } + ConfigService.configureIn(externalTool); + }; - $scope.add = function () { - var originalClass = $scope.cssClass; - $scope.cssClass = "nzb-spinning"; - NzbDownloadService.download($scope.downloader, [{ - searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id, - originalCategory: $scope.searchresult.originalCategory, - mappedCategory: $scope.searchresult.category - }], $scope.alwaysAsk).then(function (response) { - if (response !== "dismissed") { - if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) { - $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-success" : "nzbget-success"; - } else { - $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-error" : "nzbget-error"; - growl.error(response.data.message); + $scope.$on('$stateChangeStart', + function (event, toState, toParams, fromState, fromParams) { + if ($scope.isSavingNeeded()) { + event.preventDefault(); + ModalService.open("Unsaved changed", "Do you want to save before leaving?", { + yes: { + onYes: function () { + $scope.submit(); + $state.go(toState); + }, + text: "Yes" + }, + no: { + onNo: function () { + $scope.ignoreSaveNeeded = true; + $scope.allTabs[$scope.activeTab].options.resetModel(); + $state.go(toState); + }, + text: "No" + }, + cancel: { + onCancel: function () { + event.preventDefault(); + }, + text: "Cancel" } - } else { - $scope.cssClass = originalClass; - } - }, function () { - $scope.cssClass = $scope.downloader.downloaderType === "SABNZBD" ? "sabnzbd-error" : "nzbget-error"; - growl.error("An unexpected error occurred while trying to contact NZBHydra or add the NZB."); - }) - }; - } + }); + } + }); + + $scope.$watch("$scope.form.$valid", function () { + }); + + $scope.$on('$formValidity', function (event, isValid) { + console.log("Received $formValidity event: " + isValid); + $scope.form.$valid = isValid; + $scope.form.$invalid = !isValid; + $scope.showError = !isValid; + $scope.myShowError = !isValid; + }); } + + + UpdateService.$inject = ["$http", "growl", "blockUI", "RestartService", "RequestsErrorHandler", "$uibModal", "$timeout"]; UpdateModalInstanceCtrl.$inject = ["$scope", "$http", "$interval", "RequestsErrorHandler"];angular .module('nzbhydraApp') diff --git a/core/src/main/resources/static/js/nzbhydra.js.map b/core/src/main/resources/static/js/nzbhydra.js.map index a98db6fc4..026983516 100644 --- a/core/src/main/resources/static/js/nzbhydra.js.map +++ b/core/src/main/resources/static/js/nzbhydra.js.map @@ -1 +1 @@ -{"version":3,"sources":["nzbhydra.js","config/formly-indexers.js","config/formly-downloaders.js","config/formly-config.js","config/config-service.js","config/config-fields-service.js","config/config-controller.js","directives/tasks.js","directives/tab-or-chart.js","directives/selection-button.js","directives/search-result.js","directives/save-or-send-torrent.js","directives/on-finish-render.js","directives/multiselect-dropdown.js","directives/keep-focus.js","directives/indexer-state-switch.js","directives/indexer-selection-button.js","directives/indexer-input.js","directives/hydra-updates.js","directives/hydra-news.js","directives/hydra-log.js","directives/hydra-checks-footer.js","directives/footer.js","directives/focus-on.js","directives/downloaderStatusFooter.js","directives/download-nzbzip-button.js","directives/download-nzbs-button.js","directives/dataTableDirectives.js","directives/connection-test.js","directives/click-outside.js","directives/cfg-form-entry.js","directives/backup.js","directives/addable-nzbs.js","directives/addable-nzb.js","update-service.js","system-controller.js","stats-service.js","stats-controller.js","search-service.js","search-results-controller.js","search-history-service.js","search-history-controller.js","search-controller.js","restart-service.js","nzbhydra-control-service.js","nzb-download-service.js","notifications-service.js","notification-history-controller.js","modal.js","modal-service.js","migration-service.js","login-controller.js","indexer-statuses-controller.js","index-controller.js","hydra-auth-service.js","header-controller.js","generic-storage-service.js","generic-error-handler.js","filters.js","file-selection-service.js","file-download-service.js","downloader-categories-service.js","download-history-controller.js","debug-service.js","categories-service.js","backup-service.js","angular-scroll.js"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/5BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7xCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7ZA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3lBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACvQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrzEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnVA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/CA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClRA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrIA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC1FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/LA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChWA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC1FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACdA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnHA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxXA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC5HA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC9EA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC9VA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/jCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpPA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzeA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC5FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3HA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChLA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACTA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACvFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChHA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACfA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"nzbhydra.js","sourcesContent":["// For caching HTML templates, see http://paulsalaets.com/pre-caching-angular-templates-with-gulp\nangular.module('templates', []);\n\nvar nzbhydraapp = angular.module('nzbhydraApp', ['angular-loading-bar', 'cgBusy', 'ui.bootstrap', 'ipCookie', 'angular-growl',\n 'angular.filter', 'filters', 'ui.router', 'blockUI', 'mgcrea.ngStrap', 'angularUtils.directives.dirPagination',\n 'nvd3', 'formly', 'formlyBootstrap', 'frapontillo.bootstrap-switch', 'ui.select', 'ngSanitize', 'checklist-model',\n 'ngAria', 'ngMessages', 'ui.router.title', 'LocalStorageModule', 'angular.filter', 'ngFileUpload', 'ngCookies', 'angular.chips',\n 'templates', 'base64', 'duScroll', 'colorpicker.module']);\n\nnzbhydraapp.config(['$compileProvider', function ($compileProvider) {\n $compileProvider.debugInfoEnabled(true);\n}]);\n\nnzbhydraapp.config(['$animateProvider', function ($animateProvider) {\n}]);\n\nangular.module('nzbhydraApp').config([\"$stateProvider\", \"$urlRouterProvider\", \"$locationProvider\", \"blockUIConfig\", \"$urlMatcherFactoryProvider\", \"localStorageServiceProvider\", \"bootstrapped\", function ($stateProvider, $urlRouterProvider, $locationProvider, blockUIConfig, $urlMatcherFactoryProvider, localStorageServiceProvider, bootstrapped) {\n blockUIConfig.autoBlock = false;\n blockUIConfig.resetOnException = false;\n blockUIConfig.autoInjectBodyBlock = false;\n $urlMatcherFactoryProvider.strictMode(false);\n\n $urlRouterProvider.otherwise(\"/\");\n\n $stateProvider\n .state('root', {\n url: '',\n abstract: true,\n resolve: {\n //loginRequired: loginRequired\n },\n views: {\n 'header': {\n templateUrl: 'static/html/states/header.html',\n controller: 'HeaderController',\n resolve: {\n bootstrapped: function () {\n return bootstrapped;\n }\n }\n }\n }\n })\n .state(\"root.config\", {\n url: \"/config\",\n views: {},\n abstract: true\n })\n .state(\"root.config.main\", {\n url: \"/main\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n controllerAs: 'ctrl',\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 0;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Main)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.auth\", {\n url: \"/auth\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 1;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Auth)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.searching\", {\n url: \"/searching\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 2;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Searching)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.categories\", {\n url: \"/categories\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 3;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Categories)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.downloading\", {\n url: \"/downloading\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 4;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Downloading)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.indexers\", {\n url: \"/indexers\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 5;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Indexers)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.notifications\", {\n url: \"/notifications\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 6;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Notifications)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats\", {\n url: \"/stats\",\n abstract: true,\n views: {\n 'container@': {\n templateUrl: \"static/html/states/stats.html\",\n controller: [\"$scope\", \"$state\", function ($scope, $state) {\n $scope.$state = $state;\n $scope.bootstrapped = bootstrapped;\n }],\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats\"\n }]\n }\n\n }\n }\n })\n .state(\"root.stats.main\", {\n url: \"/stats\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/main-stats.html\",\n controller: \"StatsController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.indexers\", {\n url: \"/indexers\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/indexer-statuses.html\",\n controller: IndexerStatusesController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n statuses: [\"$http\", function ($http) {\n return $http.get(\"internalapi/indexerstatuses\").then(function (response) {\n return response;\n });\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Indexers)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.searches\", {\n url: \"/searches\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/search-history.html\",\n controller: SearchHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n history: ['loginRequired', 'SearchHistoryService', function (loginRequired, SearchHistoryService) {\n return SearchHistoryService.getSearchHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Searches)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.downloads\", {\n url: \"/downloads\",\n views: {\n 'stats@root.stats': {\n templateUrl: 'static/html/states/download-history.html',\n controller: DownloadHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n downloads: [\"StatsService\", function (StatsService) {\n return StatsService.getDownloadHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Downloads)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.notifications\", {\n url: \"/notifications\",\n views: {\n 'stats@root.stats': {\n templateUrl: 'static/html/states/notification-history.html',\n controller: NotificationHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n preloadData: [\"StatsService\", function (StatsService) {\n return StatsService.getNotificationHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Notifications)\"\n }]\n }\n }\n }\n })\n .state(\"root.system\", {\n url: \"/system\",\n views: {},\n abstract: true\n })\n .state(\"root.system.control\", {\n url: \"/control\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 0;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System\"\n }]\n }\n }\n }\n })\n .state(\"root.system.updates\", {\n url: \"/updates\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 1;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Updates)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.log\", {\n url: \"/log\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 2;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Log)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.tasks\", {\n url: \"/tasks\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 3;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Tasks)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.backup\", {\n url: \"/backup\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 4;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Backup)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.bugreport\", {\n url: \"/bugreport\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 5;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Bug report)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.news\", {\n url: \"/news\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 6;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (News)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.about\", {\n url: \"/about\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n simpleInfos: ['$http', 'RequestsErrorHandler', function ($http, RequestsErrorHandler) {\n return RequestsErrorHandler.specificallyHandled(function () {\n return $http.get(\"internalapi/updates/simpleInfos\").then(\n function (response) {\n return response.data;\n }\n );\n });\n }],\n activeTab: [function () {\n return 7;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (About)\"\n }]\n }\n }\n }\n })\n\n .state(\"root.search\", {\n url: \"/?category&query&imdbId&tvdbId&title&season&episode&minsize&maxsize&minage&maxage&offsets&tvrageId&mode&tmdbId&indexers&tvmazeId&sortby&sortdirection\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/search.html\",\n controller: \"SearchController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"search\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Search\";\n }]\n }\n }\n }\n })\n .state(\"root.search.results\", {\n views: {\n 'results@root.search': {\n templateUrl: \"static/html/states/search-results.html\",\n controller: \"SearchResultsController\",\n controllerAs: \"srController\",\n options: {\n inherit: true\n },\n params: {\n modalInstance: null\n },\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"search\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n var title = \"Search results\";\n var details;\n if ($stateParams.title) {\n details = $stateParams.title;\n } else if ($stateParams.query) {\n details = $stateParams.query;\n }\n if (details) {\n title += \" (\" + details + \")\";\n }\n return title;\n }]\n }\n }\n }\n })\n .state(\"root.login\", {\n url: \"/login\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/login.html\",\n controller: \"LoginController\",\n resolve: {\n loginRequired: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Login\"\n }]\n }\n }\n }\n })\n ;\n\n\n $locationProvider.html5Mode(true);\n\n\n function loginRequired($q, $timeout, $state, HydraAuthService, type) {\n var deferred = $q.defer();\n var userInfos = HydraAuthService.getUserInfos();\n var allowed = false;\n if (type === \"search\") {\n allowed = !userInfos.searchRestricted || userInfos.maySeeSearch;\n } else if (type === \"stats\") {\n allowed = !userInfos.statsRestricted || userInfos.maySeeStats;\n } else if (type === \"admin\") {\n allowed = !userInfos.adminRestricted || userInfos.maySeeAdmin;\n } else {\n allowed = true;\n }\n if (allowed || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n\n //Because I don't know for what state the login is required / asked I have a function for each\n\n function loginRequiredSearch($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.searchRestricted || userInfos.maySeeSearch || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n function loginRequiredStats($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.statsRestricted || userInfos.maySeeStats || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n function loginRequiredAdmin($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.statsRestricted || userInfos.maySeeAdmin || userInfos.authType != \"form\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n localStorageServiceProvider\n .setPrefix('nzbhydra');\n localStorageServiceProvider\n .setNotify(true, false);\n}]);\n\n\nnzbhydraapp.config([\"paginationTemplateProvider\", function (paginationTemplateProvider) {\n paginationTemplateProvider.setPath('static/html/dirPagination.tpl.html');\n}]);\n\nnzbhydraapp.config(['cfpLoadingBarProvider', function (cfpLoadingBarProvider) {\n cfpLoadingBarProvider.latencyThreshold = 100;\n}]);\n\nnzbhydraapp.config(['growlProvider', function (growlProvider) {\n growlProvider.globalTimeToLive(5000);\n growlProvider.globalPosition('bottom-right');\n}]);\n\nnzbhydraapp.directive('ngEnter', function () {\n return function (scope, element, attr) {\n element.bind(\"keydown keypress\", function (event) {\n if (event.which === 13) {\n scope.$apply(function () {\n scope.$evalAsync(attr.ngEnter);\n });\n\n event.preventDefault();\n }\n });\n };\n});\n\nnzbhydraapp.filter('nzblink', function () {\n return function (resultItem) {\n var uri = new URI(\"internalapi/getnzb/user/\" + resultItem.searchResultId);\n return uri.toString();\n }\n});\n\nnzbhydraapp.factory('focus', [\"$rootScope\", \"$timeout\", function ($rootScope, $timeout) {\n return function (name) {\n $timeout(function () {\n $rootScope.$broadcast('focusOn', name);\n });\n }\n}]);\n\nnzbhydraapp.run([\"$rootScope\", function ($rootScope) {\n $rootScope.$on('$stateChangeSuccess',\n function (event, toState, toParams, fromState, fromParams) {\n try {\n $rootScope.title = toState.views[Object.keys(toState.views)[0]].resolve.$title[1](toParams);\n } catch (e) {\n\n }\n\n });\n}]);\n\nnzbhydraapp.filter('dereferer', [\"ConfigService\", function (ConfigService) {\n return function (url) {\n if (ConfigService.getSafe().dereferer) {\n return ConfigService.getSafe().dereferer\n .replace(\"$s\", escape(url))\n .replace(\"$us\", url);\n }\n return url;\n }\n}]);\n\nnzbhydraapp.filter('derefererExtracting', [\"ConfigService\", function (ConfigService) {\n return function (aString) {\n if (!ConfigService.getSafe().dereferer || !aString) {\n return aString\n }\n var matches = aString.match(/(http|ftp|https):\\/\\/([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?/);\n if (matches === null) {\n return aString;\n }\n\n aString = aString\n .replace(matches[0], ConfigService.getSafe().dereferer.replace(\"$s\", escape(matches[0])))\n .replace(matches[0], ConfigService.getSafe().dereferer.replace(\"$us\", matches[0]))\n ;\n\n return aString;\n }\n}]);\n\nnzbhydraapp.filter('binsearch', [\"ConfigService\", function (ConfigService) {\n return function (url) {\n return \"http://binsearch.info/?q=\" + encodeURIComponent(url) + \"&max=100&adv_age=3000&server=\";\n }\n}]);\n\nnzbhydraapp.config([\"$provide\", function ($provide) {\n $provide.decorator(\"$exceptionHandler\", ['$delegate', '$injector', function ($delegate, $injector) {\n return function (exception, cause) {\n $delegate(exception, cause);\n try {\n\n if (angular.isDefined(exception.stack)) {\n var stack = exception.stack.split('\\n').map(function (line) {\n return line.trim();\n });\n stack = stack.join(\"\\n\");\n //$injector.get(\"$http\").put(\"internalapi/logerror\", {error: stack, cause: angular.isDefined(cause) ? cause.toString() : \"No known cause\"});\n }\n } catch (e) {\n console.error(\"Unable to log JS exception to server\", e);\n }\n };\n }]);\n}]);\n\n_.mixin({\n isNullOrEmpty: function (string) {\n return (_.isUndefined(string) || _.isNull(string) || (_.isString(string) && string.length === 0))\n }\n});\n\nnzbhydraapp.factory('sessionInjector', [\"$injector\", function ($injector) {\n var sessionInjector = {\n response: function (response) {\n if (response.headers(\"Hydra-MaySeeAdmin\") != null) {\n $injector.get(\"HydraAuthService\").setLoggedInByBasic(response.headers(\"Hydra-MaySeeStats\") == \"True\", response.headers(\"Hydra-MaySeeAdmin\") == \"True\", response.headers(\"Hydra-Username\"))\n }\n\n return response;\n }\n };\n return sessionInjector;\n}]);\n\nnzbhydraapp.config(['$httpProvider', function ($httpProvider) {\n $httpProvider.interceptors.push('sessionInjector');\n $httpProvider.defaults.xsrfCookieName = 'HYDRA-XSRF-TOKEN';\n}]);\n\nnzbhydraapp.directive('autoFocus', [\"$timeout\", function ($timeout) {\n return {\n restrict: 'AC',\n link: function (_scope, _element, attrs) {\n if (attrs.noFocus) {\n return;\n }\n $timeout(function () {\n _element[0].focus();\n }, 0);\n }\n };\n}]);\n\nnzbhydraapp.factory('responseObserver', [\"$q\", \"$window\", \"growl\", function responseObserver($q, $window, growl) {\n return {\n 'responseError': function (errorResponse) {\n switch (errorResponse.status) {\n case 403:\n growl.info(\"You are not allowed to visit that section.\");\n break;\n }\n if (angular.isDefined(errorResponse.config)) {\n errorResponse.config.alreadyHandled = true;\n }\n return $q.reject(errorResponse);\n }\n };\n}]);\n\nnzbhydraapp.config([\"$httpProvider\", function ($httpProvider) {\n $httpProvider.interceptors.push('responseObserver');\n}]);\n\n\nnzbhydraapp.factory('focus', [\"$timeout\", \"$window\", function ($timeout, $window) {\n return function (id) {\n // timeout makes sure that it is invoked after any other event has been triggered.\n // e.g. click events that need to run before the focus or\n // inputs elements that are in a disabled state but are enabled when those events\n // are triggered.\n $timeout(function () {\n var element = $window.document.getElementById(id);\n if (element)\n element.focus();\n });\n };\n}]);\n\nnzbhydraapp.directive('eventFocus', [\"focus\", function (focus) {\n return function (scope, elem, attr) {\n elem.on(attr.eventFocus, function () {\n focus(attr.eventFocusId);\n });\n\n // Removes bound events in the element itself\n // when the scope is destroyed\n scope.$on('$destroy', function () {\n elem.off(attr.eventFocus);\n });\n };\n}]);\n\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nCheckCapsModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"$http\", \"$timeout\", \"growl\", \"capsCheckRequest\"];\nIndexerConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nIndexerCheckBeforeCloseService.$inject = [\"$q\", \"ModalService\", \"IndexerConfigBoxService\", \"growl\", \"blockUI\"];\nfunction regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n}\n\nfunction getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) {\n var fieldset = [];\n if (indexerModel.searchModuleType === \"TORZNAB\") {\n fieldset.push({\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\"Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api\"]\n }\n });\n }\n if ((indexerModel.searchModuleType === \"NEWZNAB\" || indexerModel.searchModuleType === \"TORZNAB\") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var message;\n var cssClass;\n if (!indexerModel.configComplete) {\n message = \"The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration.\";\n cssClass = \"alert alert-danger\";\n } else {\n message = \"The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable.\";\n cssClass = \"alert alert-warning\";\n }\n fieldset.push({\n type: 'help',\n hideExpression: 'model.allCapsChecked && model.configComplete',\n templateOptions: {\n type: 'help',\n lines: [message],\n class: cssClass\n }\n });\n }\n\n var stateHelp = \"\";\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\" || indexerModel.state === \"DISABLED_SYSTEM\") {\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\") {\n stateHelp = \"The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually\";\n } else {\n stateHelp = \"The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens.\";\n }\n }\n\n if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push(\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== indexerModel.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Indexer \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n },\n noComma:\n {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return value.indexOf(\",\") === -1;\n }\n return true;\n },\n message: '\"Name may not contain a comma\"'\n }\n }\n })\n }\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push({\n key: 'state',\n type: 'horizontalIndexerStateSwitch',\n templateOptions: {\n type: 'switch',\n label: 'State',\n help: stateHelp\n }\n });\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n var hostField = {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'http://www.someindexer.com'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n };\n if (indexerModel.searchModuleType === 'TORZNAB') {\n hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one';\n }\n fieldset.push(\n hostField\n );\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') {\n fieldset.push(\n {\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'apiPath',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API path',\n help: 'Path to the API. If empty /api is used',\n required: false,\n advanced: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Username',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n }\n\n if ('WTFNZB' === indexerModel.searchModuleType) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: true,\n label: 'Username',\n help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'password',\n type: 'passwordSwitch',\n hideExpression: '!model.username',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Password',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n }\n }\n )\n }\n\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'score',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Priority',\n required: true,\n help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.',\n tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name).
              The result from the indexer with the highest number is shown first in the GUI and returned for API searches.'\n\n }\n });\n }\n\n fieldset.push(\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout',\n min: 1,\n help: 'Supercedes the general timeout in \"Searching\".',\n advanced: true\n }\n },\n {\n key: 'schedule',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Schedule',\n help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.',\n advanced: true\n }\n }\n );\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'hitLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'API hit limit',\n help: 'Maximum number of API hits since \"API hit reset time\".',\n tooltip: 'When the maximum number of API hits is reached the indexer isn\\'t used anymore. Only API hits done by NZBHydra are taken into account.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n },\n {\n key: 'downloadLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Download limit',\n help: 'When # of downloads since \"Hit reset time\" is reached indexer will not be searched.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'hitLimitResetTime',\n type: 'horizontalInput',\n hideExpression: '!model.hitLimit && !model.downloadLimit',\n templateOptions: {\n type: 'number',\n label: 'Hit reset time',\n help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.',\n tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.'\n },\n validators: {\n timeOfDay: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return value >= 0 && value <= 23;\n },\n message: '$viewValue + \" is not a valid hour of day (0-23)\"'\n }\n }\n },\n {\n key: 'loadLimitOnRandom',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Load limiting',\n help: 'If set indexer will only be picked for one out of x API searches (on average).',\n tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.',\n advanced: true\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 1;\n },\n message: '\"Value must be greater than 1\"'\n }\n }\n }\n );\n }\n if (indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push({\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.'\n }\n })\n }\n\n if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'User agent',\n help: 'Rarely needed. Will supercede the one in the main searching settings.',\n advanced: true\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'customParameters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Custom parameters',\n help: 'Define custom parameters to be sent to the indexer when searching. Use the format \"name=value\"Apply values with return key.',\n advanced: 'true'\n }\n }\n )\n }\n\n fieldset.push(\n {\n key: 'preselect',\n type: 'horizontalSwitch',\n hideExpression: 'model.enabledForSearchSource===\"EXTERNAL\"',\n templateOptions: {\n type: 'switch',\n label: 'Preselect',\n help: 'Preselect this indexer on the search page.'\n }\n }\n );\n fieldset.push(\n {\n key: 'enabledForSearchSource',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Enable for...',\n options: [\n {name: 'Internal searches only', value: 'INTERNAL'},\n {name: 'API searches only', value: 'API'},\n {name: 'All but API update queries ', value: 'ALL_BUT_RSS'},\n {name: 'Only API update queries ', value: 'ONLY_RSS'},\n {name: 'Internal and any API searches', value: 'BOTH'}\n ],\n help: 'Select for which searches this indexer will be used. \"Update queries\" are searches without query or ID (e.g. done by Sonarr periodically).',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'color',\n type: 'colorInput',\n templateOptions: {\n label: 'Color',\n help: 'If set it will be used in the search results to mark the indexer\\'s results.',\n tooltip: 'To mark expanded results they\\'re shown in a darker shade so it\\'s recommended to use indexer colors which not only differ in lightness',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'vipExpirationDate',\n type: 'horizontalInput',\n templateOptions: {\n required: false,\n label: 'VIP expiry',\n help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or \"Lifetime\".'\n },\n validators: {\n port: regexValidator(/^(\\d{4}-\\d{2}-\\d{2})|Lifetime$/, \"is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')\", true, false)\n }\n }\n );\n\n if (indexerModel.searchModuleType !== \"ANIZB\" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var cats = CategoriesService.getWithoutAll();\n var options = _.map(cats, function (x) {\n return {id: x.name, label: x.name}\n });\n fieldset.push(\n {\n key: 'enabledCategories',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Categories',\n help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.',\n options: options,\n settings: {\n showSelectedValues: false,\n noSelectedText: \"None/All\"\n },\n advanced: true\n }\n }\n );\n }\n\n\n if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'supportedSearchIds',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search IDs',\n options: [\n {label: 'IMDB (TV)', id: 'TVIMDB'},\n {label: 'TVDB', id: 'TVDB'},\n {label: 'TVRage', id: 'TVRAGE'},\n {label: 'Trakt', id: 'TRAKT'},\n {label: 'TVMaze', id: 'TVMAZE'},\n {label: 'IMDB', id: 'IMDB'},\n {label: 'TMDB', id: 'TMDB'}\n ],\n noSelectedText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n key: 'supportedSearchTypes',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search types',\n options: [\n {label: 'Audio', id: 'AUDIO'},\n {label: 'Ebooks', id: 'BOOK'},\n {label: 'Movies', id: 'MOVIE'},\n {label: 'Search', id: 'SEARCH'},\n {label: 'TV', id: 'TVSEARCH'}\n ],\n buttonText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n type: 'horizontalCheckCaps',\n hideExpression: '!model.host || !model.name',\n templateOptions: {\n label: 'Check capabilities',\n help: 'Find out what search types and IDs the indexer supports.',\n tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.'\n }\n }\n )\n }\n\n if (indexerModel.searchModuleType === 'NZBINDEX') {\n fieldset.push(\n {\n key: 'generalMinSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Min size',\n help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category'\n }\n }\n );\n }\n\n if (indexerModel.searchModuleType === 'BINSEARCH') {\n fieldset.push({\n key: 'binsearchOtherGroups',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Search in other groups',\n help: 'If disabled binsearch will only search in the most popular usenet groups'\n }\n })\n }\n\n return fieldset;\n}\n\nfunction _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-box.html',\n controller: 'IndexerConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n indexerModel.showAdvanced = parentModel.showAdvanced;\n return indexerModel;\n },\n fields: function () {\n return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode);\n },\n form: function () {\n return form;\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n }\n ,\n info: function () {\n return indexerModel.info;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'indexers',\n templateUrl: 'static/html/config/indexer-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService) {\n $scope.showBox = showBox;\n $scope.formOptions = {formState: $scope.formState};\n $scope.showPresetSelection = showPresetSelection;\n\n function showPresetSelection() {\n $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-selection.html',\n controller: 'IndexerConfigSelectionBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n return $scope.model;\n },\n form: function () {\n return $scope.form;\n }\n }\n });\n }\n\n //Called when clicking the box of an existing indexer\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", $scope.form)\n }\n\n }\n });\n }]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$uibModal\", \"$http\", \"model\", \"form\", \"growl\", \"CategoriesService\", \"$timeout\", \"ModalService\", \"RequestsErrorHandler\", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) {\n\n $scope.showBox = showBox;\n $scope.isInitial = false;\n\n $scope.select = function (modelPreset) {\n\n addEntry(modelPreset);\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n $scope.readJackettConfig = function () {\n var indexerModel = createIndexerModel();\n indexerModel.searchModuleType = \"JACKETT_CONFIG\";\n indexerModel.isInitial = false;\n indexerModel.host = \"http://127.0.0.1:9117\";\n indexerModel.name = \"Jackett config\";\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"jackettConfig\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //User pushed button, now we read the config\n RequestsErrorHandler.specificallyHandled(function () {\n $http.post(\"internalapi/indexer/readJackettConfig\", {existingIndexers: model, jackettConfig: returnedModel}, {\n headers: {\n \"Accept\": \"application/json;charset=utf-8\",\n \"Accept-Charset\": \"charset=utf-8\"\n }\n }).then(function (response) {\n //Replace model with new result\n model.splice(0, model.length);\n _.each(response.data.newIndexersConfig, function (x) {\n model.push(x);\n });\n growl.info(\"Added \" + response.data.addedTrackers + \" new trackers from Jackett\");\n growl.info(\"Updated \" + response.data.updatedTrackers + \" trackers from Jackett\");\n\n }, function (response) {\n ModalService.open(\"Error reading jackett config\", response.data, {}, \"md\", \"left\");\n });\n });\n }\n });\n\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", form)\n }\n\n function createIndexerModel() {\n return angular.copy({\n allCapsChecked: false,\n apiKey: null,\n backend: 'NEWZNAB',\n color: null,\n configComplete: false,\n categoryMapping: null,\n downloadLimit: null,\n enabledCategories: [],\n enabledForSearchSource: \"BOTH\",\n generalMinSize: null,\n hitLimit: null,\n hitLimitResetTime: 0,\n host: null,\n loadLimitOnRandom: null,\n name: null,\n password: null,\n preselect: true,\n score: 0,\n searchModuleType: 'NEWZNAB',\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n timeout: null,\n username: null,\n userAgent: null\n });\n }\n\n function addEntry(preset) {\n if (checkAddingAllowed(model, preset)) {\n var indexerModel = createIndexerModel();\n if (angular.isDefined(preset)) {\n _.extend(indexerModel, preset);\n }\n\n $scope.isInitial = true;\n\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"indexer\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel);\n }\n });\n } else {\n growl.error(\"That predefined indexer is already configured.\"); //For now this is the only case where adding is forbidden so we use this hardcoded message \"for now\"... (;-))\n }\n }\n\n function checkAddingAllowed(existingIndexers, preset) {\n if (!preset || !(preset.searchModuleType === \"ANIZB\" || preset.searchModuleType === \"BINSEARCH\" || preset.searchModuleType === \"NZBINDEX\" || preset.searchModuleType === \"NZBCLUB\")) {\n return true;\n }\n return !_.any(existingIndexers, function (existingEntry) {\n return existingEntry.name === preset.name;\n });\n }\n\n $scope.newznabPresets = [\n {\n name: \"abNZB\",\n host: \"https://abnzb.com/\"\n },\n {\n name: \"altHUB\",\n host: \"https://api.althub.co.za\"\n },\n {\n name: \"Animetosho (Newznab)\",\n host: \"https://feed.animetosho.org\",\n categories: [\"Anime\"],\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n allCapsChecked: true,\n configComplete: true,\n categoryMapping: {\n anime: 5070,\n audiobook: null,\n comic: null,\n ebook: null,\n magazine: null,\n categories: [\n {\n id: 5070,\n name: \"Anime\",\n subCategories: []\n }\n ]\n }\n },\n {\n name: \"Digital Carnage\",\n host: \"https://digitalcarnage.info\"\n },\n {\n name: \"DogNZB\",\n host: \"https://api.dognzb.cr\"\n },\n {\n name: \"Drunken Slug\",\n host: \"https://api.drunkenslug.com\"\n },\n {\n name: \"FastNZB\",\n host: \"https://fastnzb.com\"\n },\n {\n name: \"LuluNZB\",\n host: \"https://lulunzb.com\"\n },\n {\n name: \"miatrix\",\n host: \"https://www.miatrix.com\"\n },\n {\n name: \"NZB Finder\",\n host: \"https://nzbfinder.ws\"\n },\n {\n name: \"NZBCat\",\n host: \"https://nzb.cat\"\n },\n {\n name: \"nzb.su\",\n host: \"https://api.nzb.su\"\n },\n {\n name: \"NZBGeek\",\n host: \"https://api.nzbgeek.info\"\n },\n {\n name: \"NzbNdx\",\n host: \"https://www.nzbndx.com\"\n },\n {\n name: \"NzBNooB\",\n host: \"https://www.nzbnoob.com\"\n },\n {\n name: \"NzbNation\",\n host: \"http://www.nzbnation.com/\"\n },\n {\n name: \"nzbplanet\",\n host: \"https://nzbplanet.net\"\n },\n {\n name: \"omgwtfnzbs\",\n host: \"https://api.omgwtfnzbs.org\"\n },\n {\n name: \"SceneNZBs\",\n host: \"https://scenenzbs.com\",\n info: \"If you want german or spanish (or other language specific) results make sure to add the newznab IDs in the categories config.
              For example for german UHD movies add 2145.
              You can find out the IDs by browsing https://scenenzbs.com/rss.\"\n },\n {\n name: \"spotweb.com\",\n host: \"https://spotweb.me\"\n },\n {\n name: \"Tabula-Rasa\",\n host: \"https://www.tabula-rasa.pw/api/v1/\"\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://binsearch.info\",\n loadLimitOnRandom: null,\n name: \"Binsearch\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"BINSEARCH\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://api.nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex API\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_API\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://beta.nzbindex.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBIndex Beta\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_BETA\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://www.nzbking.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBKing.com\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBKING\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: null,\n loadLimitOnRandom: null,\n name: \"WtfNzb\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"WTFNZB\",\n username: null,\n userAgent: null\n }\n ];\n\n $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n\n $scope.torznabPresets = [\n {\n allCapsChecked: false,\n configComplete: false,\n name: \"Jackett/Cardigann\",\n host: \"http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n },\n {\n categories: [\"Anime\"],\n allCapsChecked: true,\n configComplete: true,\n name: \"Animetosho (Torznab)\",\n host: \"https://feed.animetosho.org\",\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n }\n ];\n\n $scope.emptyTorznabPreset = {\n allCapsChecked: false,\n configComplete: false,\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n };\n $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n}]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"form\", \"fields\", \"isInitial\", \"parentModel\", \"growl\", \"IndexerCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n $uibModalInstance.close(model);\n } else if (form.$valid) {\n var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach(form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl);\n\nfunction CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) {\n\n var updateMessagesInterval = undefined;\n\n $scope.messages = undefined;\n $http.post(\"internalapi/indexer/checkCaps\", capsCheckRequest).then(function (response) {\n $scope.$close([response.data, capsCheckRequest.indexerConfig]);\n if (response.data.length === 0) {\n growl.info(\"No indexers were checked\");\n }\n }, function () {\n $scope.$dismiss(\"Unknown error\")\n });\n\n $timeout(\n updateMessagesInterval = $interval(function () {\n $http.get(\"internalapi/indexer/checkCapsMessages\").then(function (response) {\n var map = response.data;\n var messages = [];\n for (var name in map) {\n if (map.hasOwnProperty(name)) {\n for (var i = 0; i < map[name].length; i++) {\n var message = \"\";\n if (capsCheckRequest.checkType !== \"SINGLE\") {\n message += name + \": \";\n }\n message += map[name][i];\n messages.push(message);\n }\n }\n }\n $scope.messages = messages;\n });\n\n }, 500),\n 500);\n\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMessagesInterval)) {\n $interval.cancel(updateMessagesInterval);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerConfigBoxService', IndexerConfigBoxService);\n\nfunction IndexerConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService);\n\nfunction IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n deferred.resolve(model);\n } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) {\n checkCapsWhenClosing(scope, model).then(function () {\n deferred.resolve(model);\n }, function () {\n deferred.reject();\n });\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/indexer/checkConnection\";\n IndexerConfigBoxService.checkConnection(url, model).then(function () {\n growl.info(\"Connection to the indexer tested successfully\");\n checkCapsWhenClosing(scope, model).then(function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.resolve(data);\n }, function () {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.reject();\n });\n },\n function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n handleConnectionCheckFail(ModalService, data, model, \"indexer\", deferred);\n });\n }\n return deferred.promise;\n }\n\n //Called when the indexer dialog is closed\n function checkCapsWhenClosing(scope, model) {\n var deferred = $q.defer();\n if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) {\n\n blockUI.start(\"New indexer found. Testing its capabilities. This may take a bit...\");\n IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: \"SINGLE\"}).then(\n function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n blockUI.reset();\n scope.spinnerActive = false;\n if (data.allCapsChecked && data.configComplete) {\n growl.info(\"Successfully tested capabilites of indexer\");\n } else if (!data.allCapsChecked && data.configComplete) {\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
              Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n } else if (!data.configComplete) {\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n\n deferred.resolve(data.indexerConfig);\n },\n function () {\n blockUI.reset();\n scope.spinnerActive = false;\n model.supportedSearchIds = undefined;\n model.supportedSearchTypes = undefined;\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.\", {}, \"md\", \"left\");\n deferred.resolve();\n }).finally(\n function () {\n scope.spinnerActive = false;\n })\n } else {\n deferred.resolve();\n }\n return deferred.promise;\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nDownloaderConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nDownloaderCheckBeforeCloseService.$inject = [\"$q\", \"DownloaderConfigBoxService\", \"growl\", \"ModalService\", \"blockUI\"];\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'downloaderConfig',\n templateUrl: 'static/html/config/downloader-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope._showBox = _showBox;\n $scope.showBox = showBox;\n $scope.isInitial = false;\n $scope.presets = [\n {\n name: \"NZBGet\",\n downloaderType: \"NZBGET\",\n username: \"nzbgetx\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\",\n url: \"http://nzbget:tegbzn6789@localhost:6789\"\n },\n {\n url: \"http://localhost:8080\",\n downloaderType: \"SABNZBD\",\n name: \"SABnzbd\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\"\n }\n ];\n\n function _showBox(model, parentModel, isInitial, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/downloader-config-box.html',\n controller: 'DownloaderConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n //Isn't properly stored in parentmodel for some reason, this works just as well\n model.showAdvanced = localStorageService.get(\"showAdvanced\");\n console.log(model.showAdvanced);\n return model;\n },\n fields: function () {\n return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService);\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n },\n data: function () {\n return $scope.options.data;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n $scope.form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n }\n\n function showBox(model, parentModel) {\n $scope._showBox(model, parentModel, false)\n }\n\n $scope.addEntry = function (entriesCollection, preset) {\n var model = angular.copy({\n enabled: true\n });\n if (angular.isDefined(preset)) {\n _.extend(model, preset);\n }\n\n $scope.isInitial = true;\n\n $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);\n }\n });\n };\n\n function getDownloaderBoxFields(model, parentModel, isInitial) {\n var fieldset = [];\n\n fieldset = _.union(fieldset, [\n {\n key: 'enabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Enabled'\n }\n },\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== model.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Downloader \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n }\n }\n\n },\n {\n key: 'url',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL',\n help: 'URL with scheme and full path',\n required: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n ]);\n\n\n if (model.downloaderType === \"SABNZBD\") {\n fieldset.push({\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n } else if (model.downloaderType === \"NZBGET\") {\n fieldset.push({\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n });\n fieldset.push({\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'text',\n label: 'Password'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n }\n\n fieldset = _.union(fieldset, [\n {\n key: 'defaultCategory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Default category',\n help: 'When adding NZBs this category will be used instead of asking for the category. Write \"Use original category\", \"Use no category\" or \"Use mapped category\" to not be asked.',\n placeholder: 'Ask when downloading'\n }\n },\n {\n key: 'nzbAddingType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB adding type',\n options: [\n {name: 'Send link', value: 'SEND_LINK'},\n {name: 'Upload NZB', value: 'UPLOAD'}\n ],\n help: \"How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.\",\n tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' +\n '
              Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.',\n advanced: true\n }\n },\n {\n key: 'addPaused',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Add paused',\n help: 'Add NZBs paused',\n advanced: true\n }\n },\n {\n key: 'iconCssClass',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Icon CSS class',\n help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. \"film\")',\n placeholder: 'Default',\n tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.',\n advanced: true\n }\n }\n ]);\n\n return fieldset;\n }\n }\n });\n }]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderConfigBoxService', DownloaderConfigBoxService);\n\nfunction DownloaderConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n}\n\nangular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"fields\", \"isInitial\", \"parentModel\", \"data\", \"growl\", \"DownloaderCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if ($scope.form.$valid) {\n var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach($scope.form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n if (angular.isDefined(data.resetFunction)) {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n }\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService);\n\nfunction DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (!scope.isInitial && !scope.needsConnectionTest) {\n deferred.resolve();\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/downloader/checkConnection\";\n DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () {\n blockUI.reset();\n scope.spinnerActive = false;\n growl.info(\"Connection to the downloader tested successfully\");\n deferred.resolve();\n },\n function (data) {\n blockUI.reset();\n scope.spinnerActive = false;\n handleConnectionCheckFail(ModalService, data, model, \"downloader\", deferred);\n }).finally(function () {\n scope.spinnerActive = false;\n blockUI.reset();\n });\n }\n return deferred.promise;\n }\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nhashCode = function (s) {\n return s.split(\"\").reduce(function (a, b) {\n a = ((a << 5) - a) + b.charCodeAt(0);\n return a & a\n }, 0);\n};\n\nangular\n .module('nzbhydraApp').run([\"formlyConfig\", \"formlyValidationMessages\", function (formlyConfig, formlyValidationMessages) {\n formlyValidationMessages.addStringMessage('required', 'This field is required');\n formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid');\n formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted';\n}]);\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n formlyConfigProvider.extras.removeChromeAutoComplete = true;\n formlyConfigProvider.extras.explicitAsync = true;\n formlyConfigProvider.disableWarnings = window.onProd;\n\n\n formlyConfigProvider.setWrapper({\n name: 'settingWrapper',\n templateUrl: 'setting-wrapper.html'\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'fieldset',\n templateUrl: 'fieldset-wrapper.html',\n controller: ['$scope', function ($scope) {\n $scope.tooltipIsOpen = false;\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'help',\n template: [\n '
              ',\n '
              ',\n '
              ',\n '
              {{ line | derefererExtracting | unsafe }}
              ',\n '
              ',\n '
              ',\n '
              '\n ].join(' ')\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'logicalGroup',\n template: [\n ''\n ].join(' ')\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalInput',\n extends: 'input',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTextArea',\n extends: 'textarea',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'timeOfDay',\n extends: 'horizontalInput',\n controller: ['$scope', function ($scope) {\n $scope.model[$scope.options.key] = moment.utc($scope.model[$scope.options.key]).toDate();\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'passwordSwitch',\n extends: 'horizontalInput',\n template: [\n '
              ',\n '',\n '',\n '',\n '
              '\n ].join(' '),\n controller: function ($scope) {\n $scope.hidePassword = true;\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalChips',\n extends: 'horizontalInput',\n template: '' +\n ' ' +\n '
              ' +\n ' {{chip}}' +\n ' ' +\n '
              ' +\n '
              ' +\n ' ' +\n '
              '\n });\n\n formlyConfigProvider.setType({\n name: 'percentInput',\n template: [\n ''\n ].join(' ')\n });\n\n formlyConfigProvider.setType({\n name: 'apiKeyInput',\n template: [\n '
              ',\n '',\n '',\n '',\n '
              '\n ].join(' '),\n controller: function ($scope) {\n $scope.generate = function () {\n var result = \"\";\n var length = 24;\n var chars = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n for (var i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)];\n $scope.model[$scope.options.key] = result;\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'fileInput',\n extends: 'horizontalInput',\n template: [\n '
              ',\n '',\n '',\n '',\n '
              '\n ].join(' '),\n controller: function ($scope, FileSelectionService) {\n $scope.open = function () {\n FileSelectionService.open($scope.model[$scope.options.key], $scope.to.type).then(function (selection) {\n $scope.model[$scope.options.key] = selection;\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'colorInput',\n extends: 'horizontalInput',\n templateUrl: 'static/html/config/color-control.html',\n controller: function ($scope) {\n //Model format: rgb(116,18,18)\n //Input format: rgba(100,42,41,0.5)\n if (!_.isNullOrEmpty($scope.model.color)) {\n $scope.color = $scope.model.color;\n }\n $scope.convertColorToCss = function () {\n if (_.isNullOrEmpty($scope.model.color)) {\n return \"\";\n }\n return $scope.model.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\");\n }\n $scope.convertColorFromInput = function () {\n if (_.isNullOrEmpty($scope.color)) {\n return;\n }\n $scope.model.color = $scope.color.replace(\"rgba\", \"rgb\").replace(\",0.5)\", \")\");\n }\n $scope.clear = function () {\n $scope.model.color = null;\n $scope.color = null;\n }\n $scope.$watch(\"model.color\", function () {\n if (!_.isNullOrEmpty($scope.model.color)) {\n $scope.color = $scope.model.color;\n }\n })\n }\n });\n\n formlyConfigProvider.setType({\n name: 'testConnection',\n templateUrl: 'button-test-connection.html'\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestConnection',\n extends: 'testConnection',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'customMappingTest',\n extends: 'horizontalInput',\n template: [\n '
              ',\n '',\n '
              '\n ].join(' '),\n controller: function ($scope, $uibModal, $http) {\n $scope.open = function () {\n var model = $scope.model;\n var modelCopy = structuredClone(model);\n $uibModal.open({\n templateUrl: 'static/html/custom-mapping-help.html',\n controller: [\"$scope\", \"$uibModalInstance\", \"$http\", function ($scope, $uibModalInstance, $http) {\n $scope.model = modelCopy;\n $scope.cancel = function () {\n $uibModalInstance.close();\n }\n $scope.submit = function () {\n Object.assign(model, $scope.model)\n $uibModalInstance.close();\n\n }\n\n $scope.test = function () {\n if (!$scope.exampleInput) {\n $scope.exampleResult = \"Empty example data\";\n return;\n\n }\n console.log(\"custom mapping test\");\n $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).then(function (response) {\n console.log(response.data);\n console.log(response.data.output);\n if (response.data.error) {\n $scope.exampleResult = response.data.error;\n } else if (response.data.match) {\n $scope.exampleResult = response.data.output;\n } else {\n $scope.exampleResult = \"Input does not match example\";\n }\n }, function (response) {\n $scope.exampleResult = response.message;\n })\n }\n }],\n size: \"md\"\n })\n }\n }\n });\n\n function updateIndexerModel(model, indexerConfig) {\n model.supportedSearchIds = indexerConfig.supportedSearchIds;\n model.supportedSearchTypes = indexerConfig.supportedSearchTypes;\n model.categoryMapping = indexerConfig.categoryMapping;\n model.configComplete = indexerConfig.configComplete;\n model.allCapsChecked = indexerConfig.allCapsChecked;\n model.hitLimit = indexerConfig.hitLimit;\n model.downloadLimit = indexerConfig.downloadLimit;\n model.state = indexerConfig.state;\n model.backend = indexerConfig.backend;\n }\n\n formlyConfigProvider.setType({\n //BUtton\n name: 'checkCaps',\n templateUrl: 'button-check-caps.html',\n controller: function ($scope, IndexerConfigBoxService, ModalService, growl) {\n $scope.message = \"\";\n $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host);\n\n var testButton = \"#button-check-caps-\" + $scope.uniqueId;\n var testMessage = \"#message-check-caps-\" + $scope.uniqueId;\n\n function showSuccess() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).addClass(\"btn-success\");\n }\n\n function showError() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-danger\");\n }\n\n function showWarning() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-warning\");\n }\n\n\n //When button is clicked\n $scope.checkCaps = function () {\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\n IndexerConfigBoxService.checkCaps({\n indexerConfig: $scope.model,\n checkType: \"SINGLE\"\n }).then(function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves\n updateIndexerModel($scope.model, data.indexerConfig);\n if (data.indexerConfig.supportedSearchIds.length > 0) {\n var message = \"Supports \" + data.indexerConfig.supportedSearchIds;\n angular.element(testMessage).text(message);\n }\n if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showSuccess();\n growl.info(\"Successfully tested capabilites of indexer\");\n $scope.form.capsChecked = true;\n } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showWarning();\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
              Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n $scope.form.capsChecked = true;\n } else if (!data.configComplete) {\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n }, function (message) {\n angular.element(testMessage).text(message);\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }).finally(function () {\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalCheckCaps',\n extends: 'checkCaps',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalApiKeyInput',\n extends: 'apiKeyInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalPercentInput',\n extends: 'percentInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'switch',\n template: '
              '\n });\n\n formlyConfigProvider.setType({\n name: 'indexerStateSwitch',\n template: ''\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalIndexerStateSwitch',\n extends: 'indexerStateSwitch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'duoSetting',\n extends: 'input',\n defaultOptions: {\n className: 'col-md-9',\n templateOptions: {\n type: 'number',\n noRow: true,\n label: ''\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSwitch',\n extends: 'switch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSelect',\n extends: 'select',\n wrapper: ['settingWrapper', 'bootstrapHasError'],\n controller: function ($scope) {\n if ($scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) {\n $scope.options.templateOptions.optionsFunctionAfter($scope.model);\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalMultiselect',\n defaultOptions: {\n templateOptions: {\n optionsAttr: 'bs-options',\n ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search'\n }\n },\n template: '',\n controller: function ($scope) {\n var settings = $scope.to.settings || [];\n settings.classes = settings.classes || [];\n angular.extend(settings.classes, [\"form-control\"]);\n $scope.settings = settings;\n if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n $scope.events = {\n onToggleItem: function (item, newValue) {\n $scope.form.$setDirty(true);\n }\n }\n },\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'label',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'duolabel',\n extends: 'label',\n defaultOptions: {\n className: 'col-md-2',\n templateOptions: {\n label: '-'\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'repeatSection',\n templateUrl: 'repeatSection.html',\n controller: function ($scope) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(preset) {\n console.log(preset);\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n Object.assign(newsection, preset);\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'recheckAllCaps',\n templateUrl: 'static/html/config/recheck-all-caps.html',\n controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) {\n $scope.recheck = function (checkType) {\n IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) {\n //A bit ugly, but we have to update the current model with the new data from the list\n for (var i = 0; i < $scope.model.length; i++) {\n for (var j = 0; j < listOfResults.length; j++) {\n if ($scope.model[i].name === listOfResults[j].indexerConfig.name) {\n updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig);\n $scope.form.$setDirty(true);\n }\n }\n }\n });\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'notificationSection',\n templateUrl: 'notificationRepeatSection.html',\n controller: function ($scope, NotificationService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n $scope.eventTypes = [];\n\n var allData = NotificationService.getAllData();\n _.each(_.keys(allData), function (key) {\n $scope.eventTypes.push({\"key\": key, \"label\": allData[key].readable})\n })\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(eventType) {\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n\n var eventTypeData = NotificationService.getAllData()[eventType];\n console.log(eventTypeData);\n newsection.eventType = eventType;\n newsection.titleTemplate = eventTypeData.titleTemplate;\n newsection.bodyTemplate = eventTypeData.bodyTemplate;\n newsection.messageType = eventTypeData.messageType;\n\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n //Button\n name: 'testNotification',\n templateUrl: 'button-test-notification.html',\n controller: function ($scope, NotificationService) {\n\n\n //When button is clicked\n $scope.testNotification = function () {\n NotificationService.testNotification($scope.model.eventType)\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestNotification',\n extends: 'testNotification',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n }]);\n\n","\nConfigService.$inject = [\"$http\", \"$q\", \"$cacheFactory\", \"$uibModal\", \"bootstrapped\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('ConfigService', ConfigService);\n\nfunction ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) {\n\n ConfigureInModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"growl\", \"$interval\", \"RequestsErrorHandler\", \"localStorageService\", \"externalTool\", \"dialogInfo\"];\n var cache = $cacheFactory(\"nzbhydra\");\n var safeConfig = bootstrapped.safeConfig;\n\n return {\n set: set,\n get: get,\n getSafe: getSafe,\n invalidateSafe: invalidateSafe,\n maySeeAdminArea: maySeeAdminArea,\n reloadConfig: reloadConfig,\n apiHelp: apiHelp,\n configureIn: configureIn\n };\n\n function set(newConfig, ignoreWarnings) {\n var deferred = $q.defer();\n $http.put('internalapi/config', newConfig)\n .then(function (response) {\n if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) {\n cache.put(\"config\", newConfig);\n setTimeout(function () {\n invalidateSafe();\n }, 500)\n }\n deferred.resolve(response);\n\n }, function (errorresponse) {\n console.log(\"Error saving settings:\");\n console.log(errorresponse);\n deferred.reject(errorresponse);\n });\n return deferred.promise;\n }\n\n function reloadConfig() {\n return $http.get('internalapi/config/reload').then(function (response) {\n return response.data;\n });\n }\n\n function apiHelp() {\n return $http.get('internalapi/config/apiHelp').then(function (response) {\n return response.data;\n });\n }\n\n function get() {\n var config = cache.get(\"config\");\n if (angular.isUndefined(config)) {\n config = $http.get('internalapi/config').then(function (response) {\n return response.data;\n });\n cache.put(\"config\", config);\n }\n\n return config;\n }\n\n function getSafe() {\n return safeConfig;\n }\n\n function invalidateSafe() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get('internalapi/config/safe').then(function (response) {\n safeConfig = response.data;\n });\n });\n\n }\n\n function maySeeAdminArea() {\n function loadAll() {\n var maySeeAdminArea = cache.get(\"maySeeAdminArea\");\n if (!angular.isUndefined(maySeeAdminArea)) {\n var deferred = $q.defer();\n deferred.resolve(maySeeAdminArea);\n return deferred.promise;\n }\n\n return $http.get('internalapi/mayseeadminarea')\n .then(function (configResponse) {\n var config = configResponse.data;\n cache.put(\"maySeeAdminArea\", config);\n return configResponse.data;\n });\n }\n\n return loadAll().then(function (maySeeAdminArea) {\n return maySeeAdminArea;\n });\n }\n\n function configureIn(externalTool) {\n $uibModal.open({\n templateUrl: 'static/html/configure-in-modal.html',\n controller: ConfigureInModalInstanceCtrl,\n size: \"md\",\n resolve: {\n externalTool: function () {\n return externalTool;\n },\n dialogInfo: function () {\n return $http.get(\"internalapi/externalTools/getDialogInfo\").then(function (response) {\n return response.data;\n })\n }\n }\n })\n }\n\n function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) {\n var lastConfig = localStorageService.get(externalTool);\n\n $scope.externalTool = externalTool;\n $scope.externalToolDisplayName = externalTool;\n $scope.externalToolsMessages = [];\n $scope.closeButtonType = \"warning\";\n $scope.completed = false;\n $scope.working = false;\n $scope.showMessages = false;\n\n $scope.nzbhydraHost = dialogInfo.nzbhydraHost;\n $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured;\n $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured;\n $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured;\n $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured;\n $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured;\n $scope.addDisabledIndexers = false;\n\n if (!$scope.configureForUsenet && !$scope.configureForTorrents) {\n growl.error(\"No usenet or torrent indexers configured\");\n }\n\n\n $scope.nzbhydraName = \"NZBHydra2\";\n $scope.xdarrHost = \"http://localhost:\"\n $scope.addType = \"SINGLE\";\n $scope.enableRss = true;\n $scope.enableAutomaticSearch = true;\n $scope.enableInteractiveSearch = true;\n $scope.categories = null;\n $scope.animeCategories = null;\n $scope.priority = 0;\n $scope.useHydraPriorities = true;\n\n if (externalTool === \"Sonarr\" || externalTool === \"Sonarrv3\") {\n $scope.xdarrHost += \"8989\";\n $scope.categories = \"5030,5040\";\n if (externalTool === \"Sonarrv3\") {\n $scope.externalToolDisplayName = \"Sonarr v3+\";\n }\n } else if (externalTool === \"Radarr\" || externalTool === \"Radarrv3\") {\n $scope.xdarrHost += \"7878\";\n $scope.categories = \"2000\";\n if (externalTool === \"Radarrv3\") {\n $scope.externalToolDisplayName = \"Radarr v3+\";\n }\n } else if (externalTool === \"Lidarr\") {\n $scope.xdarrHost += \"8686\";\n $scope.categories = \"3000\";\n } else if (externalTool === \"Readarr\") {\n $scope.xdarrHost += \"8787\";\n $scope.categories = \"7020,8010\";\n }\n $scope.removeYearFromSearchString = false;\n\n if (lastConfig !== null && lastConfig !== undefined) {\n Object.assign($scope, lastConfig);\n }\n\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.submit = function (deleteOnly) {\n if ($scope.completed && !deleteOnly) {\n $uibModalInstance.dismiss();\n }\n if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) {\n growl.error(\"No usenet or torrent indexers configured\");\n return;\n }\n $scope.externalToolsMessages = [];\n $scope.spinnerActive = true;\n $scope.working = true;\n $scope.showMessages = true;\n var data = {\n\n nzbhydraName: $scope.nzbhydraName,\n externalTool: $scope.externalTool,\n nzbhydraHost: $scope.nzbhydraHost,\n addType: deleteOnly ? \"DELETE_ONLY\" : $scope.addType,\n xdarrHost: $scope.xdarrHost,\n xdarrApiKey: $scope.xdarrApiKey,\n enableRss: $scope.enableRss,\n enableAutomaticSearch: $scope.enableAutomaticSearch,\n enableInteractiveSearch: $scope.enableInteractiveSearch,\n categories: $scope.categories,\n animeCategories: $scope.animeCategories,\n removeYearFromSearchString: $scope.removeYearFromSearchString,\n earlyDownloadLimit: $scope.earlyDownloadLimit,\n multiLanguages: $scope.multiLanguages,\n configureForUsenet: $scope.configureForUsenet,\n configureForTorrents: $scope.configureForTorrents,\n additionalParameters: $scope.additionalParameters,\n minimumSeeders: $scope.minimumSeeders,\n seedRatio: $scope.seedRatio,\n seedTime: $scope.seedTime,\n seasonPackSeedTime: $scope.seasonPackSeedTime,\n discographySeedTime: $scope.discographySeedTime,\n addDisabledIndexers: $scope.addDisabledIndexers,\n priority: $scope.priority,\n useHydraPriorities: $scope.useHydraPriorities\n }\n\n localStorageService.set(externalTool, data);\n\n function updateMessages() {\n $http.get(\"internalapi/externalTools/messages\").then(function (response) {\n $scope.externalToolsMessages = response.data;\n });\n }\n\n var updateInterval = $interval(function () {\n updateMessages();\n }, 500);\n\n RequestsErrorHandler.specificallyHandled(function () {\n $scope.completed = false;\n $http.post(\"internalapi/externalTools/configure\", data).then(function (response) {\n updateMessages();\n $interval.cancel(updateInterval);\n $scope.spinnerActive = false;\n console.log(response);\n if (response.data) {\n $scope.completed = true;\n $scope.closeButtonType = \"success\";\n } else {\n $scope.working = false;\n $scope.completed = false;\n }\n }, function (error) {\n updateMessages();\n console.error(error.data);\n $interval.cancel(updateInterval);\n $scope.completed = false;\n $scope.spinnerActive = false;\n $scope.working = false;\n });\n });\n };\n\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nConfigFields.$inject = [\"$injector\"];\nangular\n .module('nzbhydraApp')\n .factory('ConfigFields', ConfigFields);\n\nfunction ConfigFields($injector) {\n return {\n getFields: getFields\n };\n\n function ipValidator() {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value)\n || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(value);\n }\n return true;\n },\n message: '$viewValue + \" is not a valid IP Address\"'\n };\n }\n\n function regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n }\n\n function getFields(rootModel, showAdvanced) {\n return {\n main: [\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Hosting'},\n fieldGroup: [\n {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'IPv4 address to bind to',\n help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.'\n },\n validators: {\n ipAddress: ipValidator()\n }\n },\n {\n key: 'port',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Port',\n required: true,\n placeholder: '5076',\n help: 'Requires restart.'\n },\n validators: {\n port: regexValidator(/^\\d{1,5}$/, \"is no valid port\", true)\n }\n },\n {\n key: 'urlBase',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL base',\n placeholder: '/nzbhydra',\n help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.',\n tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like \"/nzbhydra\". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.',\n advanced: true\n },\n validators: {\n urlBase: regexValidator(/^((\\/.*[^\\/])|\\/)$/, 'URL base has to start and may not end with /', false, true)\n }\n\n },\n {\n key: 'ssl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use SSL',\n help: 'Requires restart.',\n tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\\'s more secure and can be configured better.',\n advanced: true\n }\n },\n {\n key: 'sslKeyStore',\n hideExpression: '!model.ssl',\n type: 'fileInput',\n templateOptions: {\n label: 'SSL keystore file',\n required: true,\n type: \"file\",\n help: 'Requires restart. See wiki.'\n }\n },\n {\n key: 'sslKeyStorePassword',\n hideExpression: '!model.ssl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'password',\n label: 'SSL keystore password',\n required: true,\n help: 'Requires restart.'\n }\n }\n\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Proxy',\n tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.',\n advanced: true\n }\n ,\n fieldGroup: [\n {\n key: 'proxyType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Use proxy',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'SOCKS', value: 'SOCKS'},\n {name: 'HTTP(S)', value: 'HTTP'}\n ]\n }\n },\n {\n key: 'proxyHost',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'SOCKS proxy host',\n placeholder: 'Set to use a SOCKS proxy',\n help: \"IPv4 only\"\n }\n },\n {\n key: 'proxyPort',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'number',\n label: 'Proxy port',\n placeholder: '1080'\n }\n },\n {\n key: 'proxyUsername',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy username'\n }\n },\n {\n key: 'proxyPassword',\n type: 'passwordSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy password'\n }\n },\n {\n key: 'proxyIgnoreLocal',\n type: 'horizontalSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'switch',\n label: 'Bypass local network addresses'\n }\n },\n {\n key: 'proxyIgnoreDomains',\n type: 'horizontalChips',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.',\n label: 'Bypass domains'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'UI'},\n fieldGroup: [\n\n {\n key: 'theme',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Theme',\n options: [\n {name: 'Auto', value: 'auto'},\n {name: 'Grey', value: 'grey'},\n {name: 'Bright', value: 'bright'},\n {name: 'Dark', value: 'dark'}\n ]\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Security'},\n fieldGroup: [\n {\n key: 'apiKey',\n type: 'horizontalApiKeyInput',\n templateOptions: {\n label: 'API key',\n help: 'Alphanumeric only.',\n required: true\n },\n validators: {\n apiKey: regexValidator(/^[a-zA-Z0-9]*$/, \"API key must only contain numbers and digits\", false)\n }\n },\n {\n key: 'dereferer',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Dereferer',\n help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.',\n advanced: true\n }\n },\n {\n key: 'verifySsl',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Verify SSL certificates',\n help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.',\n advanced: true\n }\n },\n {\n key: 'verifySslDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL for...',\n help: 'Add hosts for which to disable SSL verification. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'disableSslLocally',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL locally',\n help: 'Disable SSL for local hosts.',\n advanced: true\n }\n },\n {\n key: 'sniDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SNI',\n help: 'Add a host if you get an \"unrecognized_name\" error. Apply words with return key. See wiki.',\n advanced: true\n }\n },\n {\n key: 'useCsrf',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Use CSRF protection',\n help: 'Use CSRF protection.',\n advanced: true\n }\n }\n ]\n },\n\n {\n wrapper: 'fieldset',\n key: 'logging',\n templateOptions: {\n label: 'Logging',\n tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'logfilelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Logfile level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logMaxHistory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Max log history',\n help: 'How many daily log files will be kept.'\n }\n },\n {\n key: 'consolelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Console log level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logGc',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log GC',\n help: 'Enable garbage collection logging. Only for debugging of memory issues.'\n }\n },\n {\n key: 'logIpAddresses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log IP addresses'\n }\n },\n {\n key: 'mapIpToHost',\n type: 'horizontalSwitch',\n hideExpression: '!model.logIpAddresses',\n templateOptions: {\n type: 'switch',\n label: 'Map hosts',\n help: 'Try to map logged IP addresses to host names.',\n tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.'\n }\n },\n {\n key: 'logUsername',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log user names'\n }\n },\n {\n key: 'markersToLog',\n type: 'horizontalMultiselect',\n hideExpression: 'model.consolelevel !== \"DEBUG\" && model.logfilelevel !== \"DEBUG\"',\n templateOptions: {\n label: 'Log markers',\n help: 'Select certain sections for more output on debug level. Please enable only when asked for.',\n options: [\n {label: 'API limits', id: 'LIMITS'},\n {label: 'Category mapping', id: 'CATEGORY_MAPPING'},\n {label: 'Config file handling', id: 'CONFIG_READ_WRITE'},\n {label: 'Custom mapping', id: 'CUSTOM_MAPPING'},\n {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'},\n {label: 'Duplicate detection', id: 'DUPLICATES'},\n {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'},\n {label: 'History cleanup', id: 'HISTORY_CLEANUP'},\n {label: 'HTTP', id: 'HTTP'},\n {label: 'HTTPS', id: 'HTTPS'},\n {label: 'HTTP Server', id: 'SERVER'},\n {label: 'Indexer scheduler', id: 'SCHEDULER'},\n {label: 'Notifications', id: 'NOTIFICATIONS'},\n {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'},\n {label: 'Performance', id: 'PERFORMANCE'},\n {label: 'Rejected results', id: 'RESULT_ACCEPTOR'},\n {label: 'Removed trailing words', id: 'TRAILING'},\n {label: 'URL calculation', id: 'URL_CALCULATION'},\n {label: 'User agent mapping', id: 'USER_AGENT'},\n {label: 'VIP expiry', id: 'VIP_EXPIRY'}\n ],\n buttonText: \"None\"\n }\n },\n {\n key: 'historyUserInfoType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'History user info',\n options: [\n {name: 'IP and username', value: 'BOTH'},\n {name: 'IP address', value: 'IP'},\n {name: 'Username', value: 'USERNAME'},\n {name: 'None', value: 'NONE'}\n ],\n help: 'Only affects if value is displayed in the search/download history.',\n hideExpression: '!model.keepHistory'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Backup',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'backupFolder',\n type: 'horizontalInput',\n templateOptions: {\n label: 'Backup folder',\n help: 'Either relative to the NZBHydra data folder or an absolute folder.'\n }\n },\n {\n key: 'backupEveryXDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Backup every...',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'backupBeforeUpdate',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Backup before update'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Updates'},\n fieldGroup: [\n {\n key: 'updateAutomatically',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install updates automatically'\n }\n }, {\n key: 'updateToPrereleases',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install prereleases',\n advanced: true\n }\n },\n {\n key: 'deleteBackupsAfterWeeks',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Delete backups after...',\n addonRight: {\n text: 'weeks'\n },\n advanced: true\n }\n },\n {\n key: 'showUpdateBannerOnDocker',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show update banner when managed externally',\n advanced: true,\n help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\\'t let NZBHydra update itself).'\n }\n },\n {\n key: 'showWhatsNewBanner',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show info banner after automatic updates',\n help: 'Please keep it enabled, I put some effort into the changelog ;-)',\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'History',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepHistory',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Keep history',\n help: 'Controls search and download history.',\n tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.'\n }\n },\n {\n key: 'keepHistoryForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep history for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.'\n }\n },\n {\n key: 'keepStatsForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep stats for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep stats for a certain time. Will decrease database size.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Database',\n tooltip: 'You should not change these values unless you\\'re either told to or really know what you\\'re doing.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'databaseCompactTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database compact time',\n addonRight: {\n text: 'ms'\n },\n min: 200,\n help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.'\n }\n },\n {\n key: 'databaseRetentionTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database retention time',\n addonRight: {\n text: 'ms'\n },\n help: 'How long the db should retain old, persisted data. See here.'\n }\n },\n {\n key: 'databaseWriteDelay',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database write delay',\n addonRight: {\n text: 'ms'\n },\n help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.'\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Other'},\n fieldGroup: [\n {\n key: 'startupBrowser',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Open browser on startup'\n }\n },\n {\n key: 'showNews',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show news',\n help: \"Hydra will occasionally show news when opened. You can always find them in the system section\",\n advanced: true\n }\n },\n {\n key: 'proxyImages',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Proxy images',\n help: 'Download images from indexers and info providers (e.g. TMBD) and serve them via NZBHydra. Will only affect searches via UI, not API searches.'\n }\n },\n {\n key: 'checkOpenPort',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Check for open port',\n help: \"Check if NZBHydra is reachable from the internet and not protected\",\n advanced: true\n }\n },\n {\n key: 'xmx',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'JVM memory',\n addonRight: {\n text: 'MB'\n },\n min: 128,\n help: '256 should suffice except when working with big databases / many indexers. See wiki.',\n advanced: true\n }\n }\n ]\n\n }\n ],\n\n searching: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Indexer access',\n tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout when accessing indexers',\n help: 'Any web call to an indexer taking longer than this is aborted.',\n min: 1,\n addonRight: {\n text: 'seconds'\n }\n }\n },\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'User agent',\n help: 'Used when accessing indexers.',\n required: true,\n tooltip: 'Some indexers don\\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.',\n }\n },\n {\n key: 'userAgents',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Map user agents',\n help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.',\n }\n },\n {\n key: 'ignoreLoadLimitingForInternalSearches',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore load limiting internally',\n help: 'When enabled load limiting defined for indexers will be ignored for internal searches.',\n }\n },\n {\n key: 'ignoreTemporarilyDisabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore temporary errors',\n tooltip: \"By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.\",\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Category handling',\n tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).',\n advanced: true\n },\n fieldGroup: [\n\n {\n key: 'transformNewznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Transform newznab categories',\n help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.'\n }\n },\n {\n key: 'sendTorznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send categories to trackers',\n help: 'If disabled no categories will be included in queries to torznab indexers (trackers).'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Media IDs / Query generation / Query processing',\n tooltip: 'Raw search engines like Binsearch don\\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\\'s or show\\'s title and generate a query, for example \"showname s01e01\". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.'\n },\n fieldGroup: [\n {\n key: 'alwaysConvertIds',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Convert media IDs for...',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).\",\n advanced: true\n }\n },\n {\n key: 'generateQueries',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Generate queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Generate queries for indexers which do not support ID based searches.\"\n }\n },\n {\n key: 'idFallbackToQueryGeneration',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Fallback to generated queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When no results were found for a query ID search again using a generated query (on indexer level).\"\n }\n },\n {\n key: 'language',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'text',\n label: 'Language',\n required: true,\n help: 'Used for movie query generation and autocomplete only.',\n options: [{\"name\": \"Abkhaz\", value: \"ab\"}, {\n \"name\": \"Afar\",\n value: \"aa\"\n }, {\"name\": \"Afrikaans\", value: \"af\"}, {\"name\": \"Akan\", value: \"ak\"}, {\n \"name\": \"Albanian\",\n value: \"sq\"\n }, {\"name\": \"Amharic\", value: \"am\"}, {\n \"name\": \"Arabic\",\n value: \"ar\"\n }, {\"name\": \"Aragonese\", value: \"an\"}, {\"name\": \"Armenian\", value: \"hy\"}, {\n \"name\": \"Assamese\",\n value: \"as\"\n }, {\"name\": \"Avaric\", value: \"av\"}, {\"name\": \"Avestan\", value: \"ae\"}, {\n \"name\": \"Aymara\",\n value: \"ay\"\n }, {\"name\": \"Azerbaijani\", value: \"az\"}, {\n \"name\": \"Bambara\",\n value: \"bm\"\n }, {\"name\": \"Bashkir\", value: \"ba\"}, {\n \"name\": \"Basque\",\n value: \"eu\"\n }, {\"name\": \"Belarusian\", value: \"be\"}, {\"name\": \"Bengali\", value: \"bn\"}, {\n \"name\": \"Bihari\",\n value: \"bh\"\n }, {\"name\": \"Bislama\", value: \"bi\"}, {\n \"name\": \"Bosnian\",\n value: \"bs\"\n }, {\"name\": \"Breton\", value: \"br\"}, {\"name\": \"Bulgarian\", value: \"bg\"}, {\n \"name\": \"Burmese\",\n value: \"my\"\n }, {\"name\": \"Catalan\", value: \"ca\"}, {\n \"name\": \"Chamorro\",\n value: \"ch\"\n }, {\"name\": \"Chechen\", value: \"ce\"}, {\"name\": \"Chichewa\", value: \"ny\"}, {\n \"name\": \"Chinese\",\n value: \"zh\"\n }, {\"name\": \"Chuvash\", value: \"cv\"}, {\n \"name\": \"Cornish\",\n value: \"kw\"\n }, {\"name\": \"Corsican\", value: \"co\"}, {\"name\": \"Cree\", value: \"cr\"}, {\n \"name\": \"Croatian\",\n value: \"hr\"\n }, {\"name\": \"Czech\", value: \"cs\"}, {\"name\": \"Danish\", value: \"da\"}, {\n \"name\": \"Divehi\",\n value: \"dv\"\n }, {\"name\": \"Dutch\", value: \"nl\"}, {\n \"name\": \"Dzongkha\",\n value: \"dz\"\n }, {\"name\": \"English\", value: \"en\"}, {\n \"name\": \"Esperanto\",\n value: \"eo\"\n }, {\"name\": \"Estonian\", value: \"et\"}, {\"name\": \"Ewe\", value: \"ee\"}, {\n \"name\": \"Faroese\",\n value: \"fo\"\n }, {\"name\": \"Fijian\", value: \"fj\"}, {\"name\": \"Finnish\", value: \"fi\"}, {\n \"name\": \"French\",\n value: \"fr\"\n }, {\"name\": \"Fula\", value: \"ff\"}, {\n \"name\": \"Galician\",\n value: \"gl\"\n }, {\"name\": \"Georgian\", value: \"ka\"}, {\"name\": \"German\", value: \"de\"}, {\n \"name\": \"Greek\",\n value: \"el\"\n }, {\"name\": \"Guaraní\", value: \"gn\"}, {\n \"name\": \"Gujarati\",\n value: \"gu\"\n }, {\"name\": \"Haitian\", value: \"ht\"}, {\"name\": \"Hausa\", value: \"ha\"}, {\n \"name\": \"Hebrew\",\n value: \"he\"\n }, {\"name\": \"Herero\", value: \"hz\"}, {\n \"name\": \"Hindi\",\n value: \"hi\"\n }, {\"name\": \"Hiri Motu\", value: \"ho\"}, {\n \"name\": \"Hungarian\",\n value: \"hu\"\n }, {\"name\": \"Interlingua\", value: \"ia\"}, {\n \"name\": \"Indonesian\",\n value: \"id\"\n }, {\"name\": \"Interlingue\", value: \"ie\"}, {\n \"name\": \"Irish\",\n value: \"ga\"\n }, {\"name\": \"Igbo\", value: \"ig\"}, {\"name\": \"Inupiaq\", value: \"ik\"}, {\n \"name\": \"Ido\",\n value: \"io\"\n }, {\"name\": \"Icelandic\", value: \"is\"}, {\n \"name\": \"Italian\",\n value: \"it\"\n }, {\"name\": \"Inuktitut\", value: \"iu\"}, {\"name\": \"Japanese\", value: \"ja\"}, {\n \"name\": \"Javanese\",\n value: \"jv\"\n }, {\"name\": \"Kalaallisut\", value: \"kl\"}, {\n \"name\": \"Kannada\",\n value: \"kn\"\n }, {\"name\": \"Kanuri\", value: \"kr\"}, {\"name\": \"Kashmiri\", value: \"ks\"}, {\n \"name\": \"Kazakh\",\n value: \"kk\"\n }, {\"name\": \"Khmer\", value: \"km\"}, {\n \"name\": \"Kikuyu\",\n value: \"ki\"\n }, {\"name\": \"Kinyarwanda\", value: \"rw\"}, {\"name\": \"Kyrgyz\", value: \"ky\"}, {\n \"name\": \"Komi\",\n value: \"kv\"\n }, {\"name\": \"Kongo\", value: \"kg\"}, {\"name\": \"Korean\", value: \"ko\"}, {\n \"name\": \"Kurdish\",\n value: \"ku\"\n }, {\"name\": \"Kwanyama\", value: \"kj\"}, {\n \"name\": \"Latin\",\n value: \"la\"\n }, {\"name\": \"Luxembourgish\", value: \"lb\"}, {\n \"name\": \"Ganda\",\n value: \"lg\"\n }, {\"name\": \"Limburgish\", value: \"li\"}, {\"name\": \"Lingala\", value: \"ln\"}, {\n \"name\": \"Lao\",\n value: \"lo\"\n }, {\"name\": \"Lithuanian\", value: \"lt\"}, {\n \"name\": \"Luba-Katanga\",\n value: \"lu\"\n }, {\"name\": \"Latvian\", value: \"lv\"}, {\"name\": \"Manx\", value: \"gv\"}, {\n \"name\": \"Macedonian\",\n value: \"mk\"\n }, {\"name\": \"Malagasy\", value: \"mg\"}, {\n \"name\": \"Malay\",\n value: \"ms\"\n }, {\"name\": \"Malayalam\", value: \"ml\"}, {\"name\": \"Maltese\", value: \"mt\"}, {\n \"name\": \"Māori\",\n value: \"mi\"\n }, {\"name\": \"Marathi\", value: \"mr\"}, {\n \"name\": \"Marshallese\",\n value: \"mh\"\n }, {\"name\": \"Mongolian\", value: \"mn\"}, {\"name\": \"Nauru\", value: \"na\"}, {\n \"name\": \"Navajo\",\n value: \"nv\"\n }, {\"name\": \"Northern Ndebele\", value: \"nd\"}, {\n \"name\": \"Nepali\",\n value: \"ne\"\n }, {\"name\": \"Ndonga\", value: \"ng\"}, {\n \"name\": \"Norwegian Bokmål\",\n value: \"nb\"\n }, {\"name\": \"Norwegian Nynorsk\", value: \"nn\"}, {\n \"name\": \"Norwegian\",\n value: \"no\"\n }, {\"name\": \"Nuosu\", value: \"ii\"}, {\n \"name\": \"Southern Ndebele\",\n value: \"nr\"\n }, {\"name\": \"Occitan\", value: \"oc\"}, {\n \"name\": \"Ojibwe\",\n value: \"oj\"\n }, {\"name\": \"Old Church Slavonic\", value: \"cu\"}, {\"name\": \"Oromo\", value: \"om\"}, {\n \"name\": \"Oriya\",\n value: \"or\"\n }, {\"name\": \"Ossetian\", value: \"os\"}, {\"name\": \"Panjabi\", value: \"pa\"}, {\n \"name\": \"Pāli\",\n value: \"pi\"\n }, {\"name\": \"Persian\", value: \"fa\"}, {\n \"name\": \"Polish\",\n value: \"pl\"\n }, {\"name\": \"Pashto\", value: \"ps\"}, {\n \"name\": \"Portuguese\",\n value: \"pt\"\n }, {\"name\": \"Quechua\", value: \"qu\"}, {\"name\": \"Romansh\", value: \"rm\"}, {\n \"name\": \"Kirundi\",\n value: \"rn\"\n }, {\"name\": \"Romanian\", value: \"ro\"}, {\n \"name\": \"Russian\",\n value: \"ru\"\n }, {\"name\": \"Sanskrit\", value: \"sa\"}, {\"name\": \"Sardinian\", value: \"sc\"}, {\n \"name\": \"Sindhi\",\n value: \"sd\"\n }, {\"name\": \"Northern Sami\", value: \"se\"}, {\n \"name\": \"Samoan\",\n value: \"sm\"\n }, {\"name\": \"Sango\", value: \"sg\"}, {\"name\": \"Serbian\", value: \"sr\"}, {\n \"name\": \"Gaelic\",\n value: \"gd\"\n }, {\"name\": \"Shona\", value: \"sn\"}, {\"name\": \"Sinhala\", value: \"si\"}, {\n \"name\": \"Slovak\",\n value: \"sk\"\n }, {\"name\": \"Slovene\", value: \"sl\"}, {\n \"name\": \"Somali\",\n value: \"so\"\n }, {\"name\": \"Southern Sotho\", value: \"st\"}, {\n \"name\": \"Spanish\",\n value: \"es\"\n }, {\"name\": \"Sundanese\", value: \"su\"}, {\"name\": \"Swahili\", value: \"sw\"}, {\n \"name\": \"Swati\",\n value: \"ss\"\n }, {\"name\": \"Swedish\", value: \"sv\"}, {\"name\": \"Tamil\", value: \"ta\"}, {\n \"name\": \"Telugu\",\n value: \"te\"\n }, {\"name\": \"Tajik\", value: \"tg\"}, {\n \"name\": \"Thai\",\n value: \"th\"\n }, {\"name\": \"Tigrinya\", value: \"ti\"}, {\n \"name\": \"Tibetan Standard\",\n value: \"bo\"\n }, {\"name\": \"Turkmen\", value: \"tk\"}, {\"name\": \"Tagalog\", value: \"tl\"}, {\n \"name\": \"Tswana\",\n value: \"tn\"\n }, {\"name\": \"Tonga\", value: \"to\"}, {\"name\": \"Turkish\", value: \"tr\"}, {\n \"name\": \"Tsonga\",\n value: \"ts\"\n }, {\"name\": \"Tatar\", value: \"tt\"}, {\n \"name\": \"Twi\",\n value: \"tw\"\n }, {\"name\": \"Tahitian\", value: \"ty\"}, {\n \"name\": \"Uyghur\",\n value: \"ug\"\n }, {\"name\": \"Ukrainian\", value: \"uk\"}, {\"name\": \"Urdu\", value: \"ur\"}, {\n \"name\": \"Uzbek\",\n value: \"uz\"\n }, {\"name\": \"Venda\", value: \"ve\"}, {\n \"name\": \"Vietnamese\",\n value: \"vi\"\n }, {\"name\": \"Volapük\", value: \"vo\"}, {\"name\": \"Walloon\", value: \"wa\"}, {\n \"name\": \"Welsh\",\n value: \"cy\"\n }, {\"name\": \"Wolof\", value: \"wo\"}, {\n \"name\": \"Western Frisian\",\n value: \"fy\"\n }, {\"name\": \"Xhosa\", value: \"xh\"}, {\"name\": \"Yiddish\", value: \"yi\"}, {\n \"name\": \"Yoruba\",\n value: \"yo\"\n }, {\"name\": \"Zhuang\", value: \"za\"}, {\"name\": \"Zulu\", value: \"zu\"}]\n }\n },\n {\n key: 'replaceUmlauts',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Replace umlauts',\n help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result filters',\n tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: \"ea\" matches \"something.from.ea\" but not \"release.from.other\". \"web-dl\" matches \"title.web-dl\" and \"someweb-dl\".'\n },\n fieldGroup: [\n {\n key: 'applyRestrictions',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply word filters',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word/regex filters will be applied\"\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'One forbidden word in a result title dismisses the result.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'If any of the required words is not found anywhere in a result title it\\'s also dismissed.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n\n {\n key: 'forbiddenGroups',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden groups',\n help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenPosters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden posters',\n help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'languagesToKeep',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Languages to keep',\n help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.'\n }\n },\n {\n key: 'maxAge',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Maximum results age',\n help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored.'\n }\n },\n {\n key: 'ignorePassworded',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore passworded releases',\n help: \"Not all indexers provide this information\",\n tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\\'re actually passworded.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result processing'\n },\n fieldGroup: [\n {\n key: 'wrapApiErrors',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Wrap API errors in empty results page',\n help: 'When enabled accessing tools will think the search was completed successfully but without results.',\n tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\\'t return a result. That way Hydra won\\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.',\n advanced: true\n }\n },\n {\n key: 'removeTrailing',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Remove trailing...',\n help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards (\"*\"). Apply words with return key.',\n tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.'\n }\n },\n {\n key: 'useOriginalCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use original categories',\n help: 'Enable to use the category descriptions provided by the indexer.',\n tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.',\n advanced: true\n }\n }\n ]\n },\n {\n type: 'repeatSection',\n key: 'customMappings',\n model: rootModel.searching,\n templateOptions: {\n tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.',\n btnText: 'Add new custom mapping',\n altLegendText: 'Mapping',\n headline: 'Custom mappings of queries, search titles and result titles',\n advanced: true,\n fields: [\n {\n key: 'affectedValue',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Affected value',\n options: [\n {name: 'Query', value: 'QUERY'},\n {name: 'Search title', value: 'TITLE'},\n {name: 'Result title', value: 'RESULT_TITLE'},\n ],\n required: true,\n help: \"Determines which value of the search request or result will be processed\"\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n hideExpression: 'model.affectedValue === \"RESULT_TITLE\"',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines in what context the mapping will be executed\"\n }\n },\n {\n key: 'matchAll',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Match whole string',\n help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\\'s only part of the affected value.'\n }\n },\n {\n key: 'from',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Input pattern',\n help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',\n required: true\n }\n },\n {\n key: 'to',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Output pattern',\n required: true,\n 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). Use <remove> to remove the match.'\n }\n },\n {\n type: 'customMappingTest',\n }\n ],\n defaultModel: {\n searchType: null,\n affectedValue: null,\n matchAll: true,\n from: null,\n to: null\n }\n }\n },\n\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result display'\n },\n fieldGroup: [\n {\n key: 'loadAllCachedOnInternal',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display all retrieved results',\n help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.',\n advanced: true\n }\n },\n {\n key: 'loadLimitInternal',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Display...',\n addonRight: {\n text: 'results per page'\n },\n max: 500,\n required: true,\n help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.',\n advanced: true\n }\n },\n {\n key: 'coverSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cover width',\n addonRight: {\n text: 'px'\n },\n required: true,\n help: 'Determines width of covers in search results (when enabled in display options).'\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Quick filters'\n },\n fieldGroup: [\n {\n key: 'showQuickFilterButtons',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show quick filters',\n help: 'Show quick filter buttons for movie and TV results.'\n }\n },\n {\n key: 'alwaysShowQuickFilterButtons',\n type: 'horizontalSwitch',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'switch',\n label: 'Always show quick filters',\n help: 'Show all quick filter buttons for all types of searches.',\n advanced: true\n }\n },\n {\n key: 'customQuickFilterButtons',\n type: 'horizontalChips',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'text',\n label: 'Custom quick filters',\n help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Surround with / to mark as a regex. Apply values with enter key.',\n tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name \"WEB\" to be displayed that searches for \"webdl\" and \"web-dl\" in lowercase search results.',\n advanced: true\n }\n },\n {\n key: 'preselectQuickFilterButtons',\n type: 'horizontalMultiselect',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n label: 'Preselect quickfilters',\n help: 'Choose which quickfilters will be selected by default.',\n options: [\n {id: 'source|camts', label: 'CAM / TS'},\n {id: 'source|tv', label: 'TV'},\n {id: 'source|web', label: 'WEB'},\n {id: 'source|dvd', label: 'DVD'},\n {id: 'source|bluray', label: 'Blu-Ray'},\n {id: 'quality|q480p', label: '480p'},\n {id: 'quality|q720p', label: '720p'},\n {id: 'quality|q1080p', label: '1080p'},\n {id: 'quality|q2160p', label: '2160p'},\n {id: 'other|q3d', label: '3D'},\n {id: 'other|qx265', label: 'x265'},\n {id: 'other|qhevc', label: 'HEVC'},\n ],\n optionsFunction: function (model) {\n var customQuickFilters = [];\n _.each(model.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n customQuickFilters.push({id: \"custom|\" + displayName, label: displayName})\n })\n return customQuickFilters;\n },\n tooltip: 'To select custom quickfilters you just entered please save the config first.',\n buttonText: \"None\",\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Duplicate detection',\n tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'duplicateSizeThresholdInPercent',\n type: 'horizontalPercentInput',\n templateOptions: {\n type: 'text',\n label: 'Duplicate size threshold',\n required: true,\n addonRight: {\n text: '%'\n }\n\n }\n },\n {\n key: 'duplicateAgeThreshold',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Duplicate age threshold',\n required: true,\n addonRight: {\n text: 'hours'\n }\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Other',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepSearchResultsForDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Store results for ...',\n addonRight: {\n text: 'days'\n },\n required: true,\n tooltip: 'Found results are stored in the database for this long until they\\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).'\n }\n }, {\n key: 'historyForSearching',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Recet searches in search bar',\n required: true,\n tooltip: 'The number of recent searches shown in the search bar dropdown (the icon).'\n }\n },\n {\n key: 'globalCacheTimeMinutes',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Results cache time',\n help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.',\n addonRight: {\n text: 'minutes'\n }\n }\n }\n ]\n }\n ],\n\n categoriesConfig: [\n {\n key: 'enableCategorySizes',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Category sizes',\n help: \"Preset min and max sizes depending on the selected category\",\n tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.'\n }\n },\n {\n key: 'defaultCategory',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Default category',\n options: [],\n help: \"Set a default category. Reload page to set a category you just added.\"\n },\n controller: function ($scope) {\n var options = [];\n options.push({name: 'All', value: 'All'});\n _.each($scope.model.categories, function (cat) {\n options.push({name: cat.name, value: cat.name});\n });\n $scope.to.options = options;\n }\n },\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.\",\n \"Restrictions will taken from a result's category, not the search request category which may not always be the same.\"\n ],\n marginTop: '50px',\n advanced: true\n }\n },\n {\n type: 'repeatSection',\n key: 'categories',\n model: rootModel.categoriesConfig,\n templateOptions: {\n btnText: 'Add new category',\n headline: 'Categories',\n advanced: true,\n fields: [\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n help: 'Renaming categories might cause problems with repeating searches from the history.',\n required: true\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines how indexers will be searched and if autocompletion is available in the GUI\"\n }\n },\n {\n key: 'subtype',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Sub type',\n options: [\n {name: 'Anime', value: 'ANIME'},\n {name: 'Audiobook', value: 'AUDIOBOOK'},\n {name: 'Comic', value: 'COMIC'},\n {name: 'Ebook', value: 'EBOOK'},\n {name: 'None', value: 'NONE'}\n ],\n help: \"Special search type. Used for indexer specific mappings between categories and newznab IDs\"\n }\n },\n {\n key: 'applyRestrictionsType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply restrictions',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word restrictions will be applied\"\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Must *all* be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).'\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"None may be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).'\n }\n },\n {\n wrapper: 'settingWrapper',\n templateOptions: {\n label: 'Size preset',\n help: \"Will set these values on the search page\"\n },\n fieldGroup: [\n {\n key: 'minSizePreset',\n type: 'duoSetting',\n templateOptions: {\n addonRight: {\n text: 'MB'\n }\n\n }\n },\n {\n type: 'duolabel'\n },\n {\n key: 'maxSizePreset',\n type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}}\n }\n ]\n },\n {\n key: 'applySizeLimitsToApi',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Limit API results size',\n help: \"Enable to apply the size preset to API results from this category\"\n }\n },\n {\n key: 'newznabCategories',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Newznab categories',\n help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.',\n tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of \"Movies HD\" the settings for that category are used. Otherwise it checks if it matches the \"Movies\" category and, if yes, uses that one. If that one doesn\\'t match no category settings are used.

              ' +\n 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using \"&\" to require multiple numbers to be present in a result. For example \"2010&11000\" would require a search result to contain both 2010 and 11000 for that category to match.

              ' +\n 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.'\n }\n },\n {\n key: 'ignoreResultsFrom',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Ignore results',\n options: [\n {name: 'For all searches', value: 'BOTH'},\n {name: 'For internal searches', value: 'INTERNAL'},\n {name: 'For API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Ignore results from this category\",\n tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select \"Internal\" or \"Always\" this category will also not be selectable on the search page.'\n }\n }\n\n ],\n defaultModel: {\n name: null,\n applySizeLimitsToApi: false,\n applyRestrictionsType: \"NONE\",\n forbiddenRegex: null,\n forbiddenWords: [],\n ignoreResultsFrom: \"NONE\",\n mayBeSelected: true,\n maxSizePreset: null,\n minSizePreset: null,\n newznabCategories: [],\n preselect: true,\n requiredRegex: null,\n requiredWords: [],\n searchType: \"SEARCH\",\n subtype: \"NONE\"\n }\n }\n }\n ],\n downloading: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'General',\n tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.'\n },\n fieldGroup: [\n {\n key: 'saveTorrentsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'Torrent black hole',\n help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'saveNzbsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'NZB black hole',\n help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'nzbAccessType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB access type',\n options: [\n {name: 'Proxy NZBs from indexer', value: 'PROXY'},\n {name: 'Redirect to the indexer', value: 'REDIRECT'}\n ],\n help: \"How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..\",\n tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).',\n advanced: true\n\n }\n },\n {\n key: 'externalUrl',\n type: 'horizontalInput',\n hideExpression: function ($viewValue, $modelValue, scope) {\n return !_.any(scope.model.downloaders, function (downloader) {\n return downloader.nzbAddingType === \"SEND_LINK\";\n });\n },\n templateOptions: {\n label: 'External URL',\n help: 'Used for links when sending links to the downloader.',\n tooltip: 'When using \"Add links\" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\\'s not accessible by the downloader (e.g. when it\\'s inside a docker container). Set the URL for NZBHydra that\\'s accessible by the downloader here and it will be used instead. ',\n advanced: true\n }\n },\n\n {\n key: 'fallbackForFailed',\n type: 'horizontalSelect',\n hideExpression: 'model.nzbAccessType === \"REDIRECT\"',\n templateOptions: {\n label: 'Fallback for failed downloads',\n options: [\n {name: 'GUI downloads', value: 'INTERNAL'},\n {name: 'API downloads', value: 'API'},\n {name: 'All downloads', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Fallback to similar results when a download fails. Only available when proxying NZBs (see above).\",\n tooltip: \"When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search.\"\n }\n },\n {\n key: 'sendMagnetLinks',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send magnet links',\n help: \"Enable to send magnet links to the associated program on the server machine. Won't work with docker\"\n }\n },\n {\n key: 'updateStatuses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Update statuses',\n help: \"Query your downloader for status updates of downloads\",\n advanced: true\n }\n },\n {\n key: 'showDownloaderStatus',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show downloader footer',\n help: \"Show footer with downloader status\",\n advanced: true\n }\n },\n {\n key: 'primaryDownloader',\n type: 'horizontalSelect',\n hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus',\n templateOptions: {\n label: 'Primary downloader',\n options: [],\n help: \"This downloader's state will be shown in the footer.\",\n tooltip: \"To select a downloader you just added please save the config first.\",\n optionsFunction: function (model) {\n var downloaders = [];\n _.each(model.downloaders, function (downloader) {\n downloaders.push({name: downloader.name, value: downloader.name})\n })\n return downloaders;\n },\n optionsFunctionAfter: function (model) {\n if (!model.primaryDownloader) {\n model.primaryDownloader = model.downloaders[0].name;\n }\n }\n }\n },\n ]\n },\n {\n wrapper: 'fieldset',\n key: 'downloaders',\n templateOptions: {label: 'Downloaders'},\n fieldGroup: [\n {\n type: \"downloaderConfig\",\n data: {}\n }\n ]\n }\n ],\n\n indexers: [\n {\n type: \"indexers\",\n data: {}\n },\n {\n type: 'recheckAllCaps'\n }\n ],\n auth: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main',\n\n },\n fieldGroup: [\n {\n key: 'authType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Auth type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'HTTP Basic auth', value: 'BASIC'},\n {name: 'Login form', value: 'FORM'}\n ],\n tooltip: '
                ' +\n '
              • With auth type \"None\" all areas are unrestricted.
              • ' +\n '
              • With auth type \"Form\" the basic page is loaded and login is done via a form.
              • ' +\n '
              • With auth type \"Basic\" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
              • ' +\n '
              '\n }\n },\n {\n key: 'authHeader',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Auth header',\n help: 'Name of header that provides the username in requests from secure sources.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'authHeaderIpRanges',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Secure IP ranges',\n help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like \"192.168.0.1-192.168.0.100\" or single IP addresses like \"127.0.0.1\".',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\" || _.isNullOrEmpty(rootModel.auth.authHeader);\n }\n },\n {\n key: 'rememberUsers',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Remember users',\n help: 'Remember users with cookie for 14 days.'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'rememberMeValidityDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cookie expiry',\n help: 'How long users are remembered.',\n addonRight: {\n text: 'days'\n },\n advanced: true\n }\n }\n\n ]\n },\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Restrictions',\n tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\\'t to allow anonymous users to do anything just leave everything selected.
              You can decide for every user if he is allowed to:
              ' +\n '
                \\n' +\n '
              • view the search page at all
              • \\n' +\n '
              • view the stats
              • \\n' +\n '
              • access the admin area (config and control)
              • \\n' +\n '
              • view links for downloading NZBs and see their details
              • \\n' +\n '
              • may select which indexers are used for search.
              • \\n' +\n '
              '\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n fieldGroup: [\n {\n key: 'restrictSearch',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict searching',\n help: 'Restrict access to searching.'\n }\n },\n {\n key: 'restrictStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict stats',\n help: 'Restrict access to stats.'\n }\n },\n {\n key: 'restrictAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict admin',\n help: 'Restrict access to admin functions.'\n }\n },\n {\n key: 'restrictDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict NZB details & DL',\n help: 'Restrict NZB details, comments and download links.'\n }\n },\n {\n key: 'restrictIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict indexer selection box',\n help: 'Restrict visibility of indexer selection box in search. Affects only GUI.'\n }\n },\n {\n key: 'allowApiStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Allow stats access',\n help: 'Allow access to stats via external API.'\n }\n }\n ]\n },\n\n {\n type: 'repeatSection',\n key: 'users',\n model: rootModel.auth,\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n templateOptions: {\n btnText: 'Add new user',\n altLegendText: 'Authless',\n headline: 'Users',\n fields: [\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username',\n required: true\n }\n },\n {\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'password',\n label: 'Password',\n required: true\n }\n },\n {\n key: 'maySeeAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see admin area'\n }\n },\n {\n key: 'maySeeStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see stats'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'maySeeDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see NZB details & DL links'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'showIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see indexer selection box'\n },\n hideExpression: 'model.maySeeAdmin'\n }\n ],\n defaultModel: {\n username: null,\n password: null,\n token: null,\n maySeeStats: true,\n maySeeAdmin: true,\n maySeeDetailsDl: true,\n showIndexerSelection: true\n }\n }\n }\n ],\n notificationConfig: [\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.\",\n 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.',\n \"NZBHydra will also show notifications on the GUI if enabled.\"\n ]\n }\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main'\n },\n fieldGroup: [\n\n {\n key: 'appriseType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Apprise type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'API', value: 'API'},\n {name: 'CLI', value: 'CLI'}\n ]\n }\n },\n {\n key: 'appriseApiUrl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Apprise API URL',\n help: 'URL of Apprise API to send notifications to.'\n },\n hideExpression: 'model.appriseType !== \"API\"'\n },\n {\n key: 'appriseCliPath',\n type: 'fileInput',\n templateOptions: {\n type: 'file',\n label: 'Apprise runnable',\n help: 'Full path of of Apprise runnable to execute.'\n },\n hideExpression: 'model.appriseType !== \"CLI\"'\n },\n {\n key: 'displayNotifications',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display notifications',\n help: 'If enabled notifications will be shown on the GUI.'\n }\n },\n {\n key: 'displayNotificationsMax',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Show max notifications',\n help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.'\n },\n hideExpression: '!model.displayNotifications'\n },\n {\n key: 'filterOuts',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Hide if message contains...',\n help: 'Apply values with return key. Surround with \"/\" for regex (e.g. /contains[0-9]This/). Case insensitive.',\n\n },\n hideExpression: '!model.displayNotifications'\n }\n ]\n },\n\n {\n type: 'notificationSection',\n key: 'entries',\n model: rootModel.notificationConfig,\n templateOptions: {\n btnText: 'Add new notification',\n altLegendText: 'Notification',\n headline: 'Notifications',\n fields: [\n {\n key: 'appriseUrls',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URLs',\n help: 'One or more URLs identifying where the notification should be sent to, comma-separated.'\n }\n },\n {\n key: 'titleTemplate',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Title template'\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTextArea',\n templateOptions: {\n type: 'text',\n label: 'Body template',\n required: true\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'messageType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Message type',\n options: [\n {name: 'Info', value: 'INFO'},\n {name: 'Success', value: 'SUCCESS'},\n {name: 'Warning', value: 'WARNING'},\n {name: 'Failure', value: 'FAILURE'}\n ],\n help: \"Select the message type to use.\"\n }\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTestNotification'\n }\n\n ],\n defaultModel: {\n eventType: null,\n appriseUrls: null,\n titleTemplate: null,\n bodyTemplate: null,\n messageType: 'WARNING'\n }\n }\n }\n ]\n\n }\n\n function notificationTemplateHelpController($scope, NotificationService) {\n $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType);\n $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType);\n }\n }\n}\n\nfunction handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) {\n var message;\n var yesText;\n if (data.checked) {\n message = \"The connection to the \" + whatFailed + \" failed: \" + data.message + \"
              Do you want to add it anyway?\";\n yesText = \"I know what I'm doing\";\n } else {\n message = \"The connection to the \" + whatFailed + \" could not be tested, sorry. Please check the log.\";\n yesText = \"I'll risk it\";\n }\n ModalService.open(\"Connection check failed\", message, {\n yes: {\n onYes: function () {\n deferred.resolve();\n },\n text: yesText\n },\n no: {\n onNo: function () {\n model.enabled = false;\n deferred.resolve();\n },\n text: \"Add it, but disabled\"\n },\n cancel: {\n onCancel: function () {\n deferred.reject();\n },\n text: \"Aahh, let me try again\"\n }\n });\n}\n","\nConfigController.$inject = [\"$scope\", \"$http\", \"activeTab\", \"ConfigService\", \"config\", \"DownloaderCategoriesService\", \"ConfigFields\", \"ConfigModel\", \"ModalService\", \"RestartService\", \"localStorageService\", \"$state\", \"growl\", \"$window\"];angular\n .module('nzbhydraApp')\n .factory('ConfigModel', function () {\n return {};\n });\n\nangular\n .module('nzbhydraApp')\n .factory('ConfigWatcher', function () {\n var $scope;\n\n return {\n watch: watch\n };\n\n function watch(scope) {\n $scope = scope;\n $scope.$watchGroup([\"config.main.host\"], function () {\n }, true);\n }\n });\n\n\nangular\n .module('nzbhydraApp')\n .controller('ConfigController', ConfigController);\n\nfunction ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) {\n $scope.config = config;\n $scope.submit = submit;\n $scope.activeTab = activeTab;\n\n $scope.restartRequired = false;\n $scope.ignoreSaveNeeded = false;\n console.log(localStorageService.get(\"showAdvanced\"));\n if (localStorageService.get(\"showAdvanced\") === null) {\n $scope.showAdvanced = false;\n localStorageService.set(\"showAdvanced\", false);\n } else {\n $scope.showAdvanced = localStorageService.get(\"showAdvanced\");\n }\n\n\n $scope.toggleShowAdvanced = function () {\n $scope.showAdvanced = !$scope.showAdvanced;\n var wasDirty = $scope.form.$dirty === true;\n\n $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true;\n //Also save in main tab where it will be stored to file\n $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true;\n $scope.form.$dirty = wasDirty;\n localStorageService.set(\"showAdvanced\", $scope.showAdvanced);\n }\n\n function updateAndAskForRestartIfNecessary(responseData) {\n if (angular.isUndefined($scope.form)) {\n console.error(\"Unable to determine if a restart is necessary\");\n return;\n }\n\n $scope.form.$setPristine();\n DownloaderCategoriesService.invalidate();\n if ($scope.restartRequired) {\n ModalService.open(\"Restart required\", \"The changes you have made may require a restart to be effective.
              Do you want to restart now?\", {\n yes: {\n onYes: function () {\n RestartService.restart();\n }\n },\n no: {\n onNo: function ($uibModalInstance) {\n //Needs to be clicked twice for some reason\n $scope.restartRequired = false;\n $uibModalInstance.dismiss();\n $uibModalInstance.dismiss();\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n });\n } else {\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n\n function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) {\n if (angular.isUndefined(ignoreWarnings)) {\n ignoreWarnings = localStorageService.get(\"ignoreWarnings\") !== null ? localStorageService.get(\"ignoreWarnings\") : false;\n }\n //Communication with server was successful but there might be validation errors and/or warnings\n var warningMessages = response.data.warningMessages;\n var errorMessages = response.data.errorMessages;\n $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false);\n var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings);\n\n function extendMessageWithList(message, messages) {\n _.forEach(messages, function (x) {\n message += \"
            • \" + x + \"
            • \";\n });\n message += \"
            \";\n return message;\n }\n\n if (showMessage) {\n var options;\n var message;\n var title;\n if (errorMessages.length > 0) { //Actual errors which cannot be ignored\n title = \"Config validation failed\";\n message = 'The following errors have been found in your config. They need to be fixed.
              ';\n message = extendMessageWithList(message, response.data.errorMessages);\n if (warningMessages.length > 0) {\n message += '
              The following warnings were found. You can ignore them if you wish.
                ';\n message = extendMessageWithList(message, response.data.warningMessages);\n }\n options = {\n yes: {\n onYes: function () {\n },\n text: \"OK\"\n }\n };\n } else if (warningMessages.length > 0) {\n title = \"Config validation warnings\";\n message = '
                The following warnings have been found. You can ignore them if you wish. The config was already saved.
                  ';\n message = extendMessageWithList(message, response.data.warningMessages);\n options = {\n // cancel: {\n // onCancel: function () {\n // $scope.form.$setPristine();\n // localStorageService.set(\"ignoreWarnings\", true);\n // ConfigService.set($scope.config, true).then(function (response) {\n // handleConfigSetResponse(response, true, $scope.restartRequired);\n // updateAndAskForRestartIfNecessary(response.data);\n // }, function (response) {\n // //Actual error while setting or validating config\n // growl.error(response.data);\n // });\n // },\n // text: \"OK, don't show warnings again\"\n // },\n yes: {\n onYes: function () {\n handleConfigSetResponse(response, true, $scope.restartRequired);\n updateAndAskForRestartIfNecessary(response.data);\n },\n text: \"OK\"\n }\n };\n }\n ModalService.open(title, message, options, \"md\", \"left\");\n } else {\n updateAndAskForRestartIfNecessary(response.data);\n }\n }\n\n function submit() {\n if ($scope.form.$valid && !$scope.myShowError) {\n ConfigService.set($scope.config, true).then(function (response) {\n handleConfigSetResponse(response);\n }, function (response) {\n //Actual error while setting or validating config\n growl.error(response.data);\n });\n\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n\n //Ridiculously hacky way to make the error messages appear\n try {\n if (angular.isDefined(form.$error.required)) {\n _.each(form.$error.required, function (item) {\n if (angular.isDefined(item.$error.required)) {\n _.each(item.$error.required, function (item2) {\n item2.$setTouched();\n });\n }\n });\n }\n angular.forEach($scope.form.$error.required, function (field) {\n field.$setTouched();\n });\n } catch (err) {\n //\n }\n\n }\n }\n\n ConfigModel = config;\n\n $scope.fields = ConfigFields.getFields($scope.config);\n\n $scope.allTabs = [\n {\n active: false,\n state: 'root.config.main',\n name: 'Main',\n model: ConfigModel.main,\n fields: $scope.fields.main\n },\n {\n active: false,\n state: 'root.config.auth',\n name: 'Authorization',\n model: ConfigModel.auth,\n fields: $scope.fields.auth,\n options: {}\n },\n {\n active: false,\n state: 'root.config.searching',\n name: 'Searching',\n model: ConfigModel.searching,\n fields: $scope.fields.searching,\n options: {}\n },\n {\n active: false,\n state: 'root.config.categories',\n name: 'Categories',\n model: ConfigModel.categoriesConfig,\n fields: $scope.fields.categoriesConfig,\n options: {}\n },\n {\n active: false,\n state: 'root.config.downloading',\n name: 'Downloading',\n model: ConfigModel.downloading,\n fields: $scope.fields.downloading,\n options: {}\n },\n {\n active: false,\n state: 'root.config.indexers',\n name: 'Indexers',\n model: ConfigModel.indexers,\n fields: $scope.fields.indexers,\n options: {}\n },\n {\n active: false,\n state: 'root.config.notifications',\n name: 'Notifications',\n model: ConfigModel.notificationConfig,\n fields: $scope.fields.notificationConfig,\n options: {}\n }\n ];\n\n //Copy showAdvanced setting over from main tab's setting\n _.each($scope.allTabs, function (tab) {\n tab.model.showAdvanced = $scope.showAdvanced === true;\n })\n\n $scope.isSavingNeeded = function () {\n return $scope.form.$dirty && $scope.form.$valid && !$scope.ignoreSaveNeeded;\n };\n\n $scope.goToConfigState = function (index) {\n $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true});\n };\n\n $scope.apiHelp = function () {\n\n if ($scope.isSavingNeeded()) {\n growl.info(\"Please save first\");\n return;\n }\n var apiHelp = ConfigService.apiHelp().then(function (data) {\n\n var html = '' +\n '' +\n '' +\n '' +\n '
                  Newznab API endpoint:%newznab%
                  Torznab API endpoint:%torznab%
                  API key:%apikey%
                  ';\n //Torznab API endpoint: %torznab%
                  API key: %apikey%\n html = html.replace(\"%newznab%\", data.newznabApi);\n html = html.replace(\"%torznab%\", data.torznabApi);\n html = html.replace(\"%apikey%\", data.apiKey);\n ModalService.open(\"API infos\", html, {}, \"md\");\n });\n };\n\n $scope.configureIn = function (externalTool) {\n\n if ($scope.isSavingNeeded()) {\n growl.info(\"Please save first\");\n return;\n }\n ConfigService.configureIn(externalTool);\n };\n\n $scope.$on('$stateChangeStart',\n function (event, toState, toParams, fromState, fromParams) {\n if ($scope.isSavingNeeded()) {\n event.preventDefault();\n ModalService.open(\"Unsaved changed\", \"Do you want to save before leaving?\", {\n yes: {\n onYes: function () {\n $scope.submit();\n $state.go(toState);\n },\n text: \"Yes\"\n },\n no: {\n onNo: function () {\n $scope.ignoreSaveNeeded = true;\n $scope.allTabs[$scope.activeTab].options.resetModel();\n $state.go(toState);\n },\n text: \"No\"\n },\n cancel: {\n onCancel: function () {\n event.preventDefault();\n },\n text: \"Cancel\"\n }\n });\n }\n });\n\n $scope.$watch(\"$scope.form.$valid\", function () {\n });\n\n $scope.$on('$formValidity', function (event, isValid) {\n console.log(\"Received $formValidity event: \" + isValid);\n $scope.form.$valid = isValid;\n $scope.form.$invalid = !isValid;\n $scope.showError = !isValid;\n $scope.myShowError = !isValid;\n });\n}\n\n\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('hydraTasks', hydraTasks);\n\nfunction hydraTasks() {\n controller.$inject = [\"$scope\", \"$http\"];\n return {\n templateUrl: 'static/html/directives/tasks.html',\n controller: controller\n };\n\n function controller($scope, $http) {\n\n $http.get(\"internalapi/tasks\").then(function (response) {\n $scope.tasks = response.data;\n });\n\n $scope.runTask = function (taskName) {\n $http.put(\"internalapi/tasks/\" + taskName).then(function (response) {\n $scope.tasks = response.data;\n });\n }\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('tabOrChart', tabOrChart);\r\n\r\nfunction tabOrChart() {\r\n return {\r\n templateUrl: 'static/html/directives/tab-or-chart.html',\r\n transclude: {\r\n \"chartSlot\": \"chart\",\r\n \"tableSlot\": \"table\"\r\n },\r\n restrict: 'E',\r\n replace: true,\r\n scope: {\r\n display: \"@\"\r\n }\r\n\r\n };\r\n\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('selectionButton', selectionButton);\r\n\r\nfunction selectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/selection-button.html',\r\n scope: {\r\n selected: \"=\",\r\n selectable: \"=\",\r\n invertSelection: \"<\",\r\n selectAll: \"<\",\r\n deselectAll: \"<\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n if (angular.isUndefined($scope.btn)) {\r\n $scope.btn = \"default\"; //Will form class \"btn-default\"\r\n }\r\n\r\n if (angular.isUndefined($scope.invertSelection)) {\r\n $scope.invertSelection = function () {\r\n $scope.selected = _.difference($scope.selectable, $scope.selected);\r\n };\r\n }\r\n\r\n if (angular.isUndefined($scope.selectAll)) {\r\n $scope.selectAll = function () {\r\n $scope.selected.push.apply($scope.selected, $scope.selectable);\r\n };\r\n }\r\n\r\n if (angular.isUndefined($scope.deselectAll)) {\r\n $scope.deselectAll = function () {\r\n $scope.selected.splice(0, $scope.selected.length);\r\n };\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\n","\nNfoModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"nfo\"];angular\n .module('nzbhydraApp')\n .directive('searchResult', searchResult);\n\nfunction searchResult() {\n controller.$inject = [\"$scope\", \"$element\", \"$http\", \"growl\", \"$attrs\", \"$uibModal\", \"$window\", \"DebugService\", \"localStorageService\", \"HydraAuthService\", \"ConfigService\"];\n return {\n templateUrl: 'static/html/directives/search-result.html',\n require: '^result',\n replace: false,\n scope: {\n result: \"<\",\n searchResultsControllerShared: \"<\"\n },\n controller: controller\n };\n\n\n function handleDisplay($scope, localStorageService, ConfigService) {\n //Display state / expansion\n $scope.foo.duplicatesDisplayed = localStorageService.get(\"duplicatesDisplayed\") !== null ? localStorageService.get(\"duplicatesDisplayed\") : false;\n $scope.foo.showCovers = localStorageService.get(\"showCovers\") !== null ? localStorageService.get(\"showCovers\") : true;\n $scope.foo.alwaysShowTitles = localStorageService.get(\"alwaysShowTitles\") !== null ? localStorageService.get(\"alwaysShowTitles\") : true;\n $scope.duplicatesExpanded = false;\n $scope.titlesExpanded = $scope.searchResultsControllerShared.expandGroupsByDefault;\n $scope.coverSize = ConfigService.getSafe().searching.coverSize;\n\n function calculateDisplayState() {\n $scope.resultDisplayed = ($scope.result.titleGroupIndex === 0 || $scope.titlesExpanded) && ($scope.duplicatesExpanded || $scope.result.duplicateGroupIndex === 0);\n }\n\n calculateDisplayState();\n\n $scope.toggleTitleExpansion = function () {\n $scope.titlesExpanded = !$scope.titlesExpanded;\n $scope.$emit(\"toggleTitleExpansionUp\", $scope.titlesExpanded, $scope.result.titleGroupIndicator);\n };\n\n $scope.toggleDuplicateExpansion = function () {\n $scope.duplicatesExpanded = !$scope.duplicatesExpanded;\n $scope.$emit(\"toggleDuplicateExpansionUp\", $scope.duplicatesExpanded, $scope.result.hash);\n };\n\n $scope.$on(\"toggleTitleExpansionDown\", function ($event, value, titleGroupIndicator) {\n if ($scope.result.titleGroupIndicator === titleGroupIndicator) {\n $scope.titlesExpanded = value;\n calculateDisplayState();\n }\n });\n\n $scope.$on(\"toggleDuplicateExpansionDown\", function ($event, value, hash) {\n if ($scope.result.hash === hash) {\n $scope.duplicatesExpanded = value;\n calculateDisplayState();\n }\n });\n\n $scope.$on(\"toggleShowCovers\", function ($event, value) {\n $scope.foo.showCovers = value;\n });\n\n $scope.$on(\"toggleAlwaysShowTitles\", function ($event, value) {\n $scope.foo.alwaysShowTitles = value;\n console.log(\"alwaysShowTitles: \" + alwaysShowTitles);\n });\n\n $scope.$on(\"duplicatesDisplayed\", function ($event, value) {\n $scope.foo.duplicatesDisplayed = value;\n if (!value) {\n //Collapse duplicate groups they shouldn't be displayed\n $scope.duplicatesExpanded = false;\n }\n calculateDisplayState();\n });\n\n $scope.$on(\"calculateDisplayState\", function () {\n calculateDisplayState();\n });\n }\n\n function handleSelection($scope, $element) {\n $scope.foo.selected = false;\n\n function sendSelectionEvent(isSelected) {\n $scope.$emit(\"selectionUp\", $scope.result, isSelected);\n }\n\n $scope.clickCheckbox = function (event, result) {\n var isSelected = event.currentTarget.checked;\n sendSelectionEvent(isSelected);\n $scope.$emit(\"checkboxClicked\", event, isSelected, event.currentTarget);\n };\n\n function isBetween(num, betweena, betweenb) {\n return (betweena <= num && num <= betweenb) || (betweena >= num && num >= betweenb);\n }\n\n $scope.$on(\"shiftClick\", function (event, newValue, previousClickTargetElement, newClickTargetElement) {\n //Parent needs to be the td, between checkbox and td are two divs\n var fromYlocation = $(previousClickTargetElement).parent().parent().parent().prop(\"offsetTop\");\n var newYlocation = $(newClickTargetElement).parent().parent().parent().prop(\"offsetTop\");\n var elementYlocation = $($element).prop(\"offsetTop\");\n if (!$scope.resultDisplayed) {\n return;\n }\n\n if (isBetween(elementYlocation, fromYlocation, newYlocation)) {\n sendSelectionEvent(newValue);\n $scope.foo.selected = newValue === 1;\n }\n });\n\n $scope.$on(\"invertSelection\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = !$scope.foo.selected;\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"deselectAll\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = false;\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"selectAll\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = true;\n\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"toggleSelection\", function ($event, result, value) {\n if (!$scope.resultDisplayed || result !== $scope.result) {\n return;\n }\n $scope.foo.selected = value;\n });\n }\n\n function handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService) {\n $scope.showDetailsDl = HydraAuthService.getUserInfos().maySeeDetailsDl;\n\n $scope.showNfo = showNfo;\n\n function showNfo(resultItem) {\n if (resultItem.has_nfo === 0) {\n return;\n }\n var uri = new URI(\"internalapi/nfo/\" + resultItem.searchResultId);\n return $http.get(uri.toString()).then(function (response) {\n if (response.data.successful) {\n if (response.data.hasNfo) {\n $scope.openModal(\"lg\", response.data.content)\n } else {\n growl.info(\"No NFO available\");\n }\n } else {\n growl.error(response.data.content);\n }\n });\n }\n\n $scope.openModal = openModal;\n\n function openModal(size, nfo) {\n var modalInstance = $uibModal.open({\n template: '
                  ',\n controller: NfoModalInstanceCtrl,\n size: size,\n resolve: {\n nfo: function () {\n return nfo;\n }\n }\n });\n\n modalInstance.result.then();\n }\n\n $scope.getNfoTooltip = function () {\n if ($scope.result.hasNfo === \"YES\") {\n return \"Show NFO\"\n } else if ($scope.result.hasNfo === \"MAYBE\") {\n return \"Try to load NFO (may not be available)\";\n } else {\n return \"No NFO available\";\n }\n };\n }\n\n function handleNzbDownload($scope, $window) {\n $scope.downloadNzb = downloadNzb;\n\n function downloadNzb(resultItem) {\n //href = \"{{ result.link }}\"\n $window.location.href = resultItem.link;\n }\n }\n\n\n function controller($scope, $element, $http, growl, $attrs, $uibModal, $window, DebugService, localStorageService, HydraAuthService, ConfigService) {\n $scope.foo = {};\n handleDisplay($scope, localStorageService, ConfigService);\n handleSelection($scope, $element);\n handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService);\n handleNzbDownload($scope, $window);\n\n $scope.kify = function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n };\n };\n\n\n $scope.showCover = function (url) {\n console.log(\"Show \" + url);\n $uibModal.open({\n template: '
                  \\n' +\n ' \\n' +\n '
                  ',\n controller: [\"$scope\", \"url\", function ($scope, url) {\n $scope.url = url;\n }],\n resolve: {\n url: function () {\n return url;\n }\n },\n size: \"md\",\n keyboard: true,\n windowTopClass: 'cover-modal-dialog'\n });\n };\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl);\n\nfunction NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) {\n\n $scope.nfo = nfo;\n\n $scope.ok = function () {\n $uibModalInstance.close($scope.selected.item);\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .filter('kify', function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n }\n });\n","angular\r\n .module('nzbhydraApp')\r\n .directive('saveOrSendFile', saveOrSendFile);\r\n\r\nfunction saveOrSendFile() {\r\n controller.$inject = [\"$scope\", \"$http\", \"growl\", \"ConfigService\"];\r\n return {\r\n templateUrl: 'static/html/directives/save-or-send-file.html',\r\n scope: {\r\n searchResultId: \"<\",\r\n isFile: \"<\",\r\n type: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, growl, ConfigService) {\r\n $scope.cssClass = \"glyphicon-save-file\";\r\n var endpoint;\r\n if ($scope.type === \"TORRENT\") {\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks;\r\n $scope.tooltip = \"Save torrent to black hole or send magnet link\";\r\n endpoint = \"internalapi/saveOrSendTorrent\";\r\n } else {\r\n $scope.tooltip = \"Save NZB to black hole\";\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo);\r\n endpoint = \"internalapi/saveNzbToBlackhole\";\r\n }\r\n $scope.add = function () {\r\n $scope.cssClass = \"nzb-spinning\";\r\n $http.put(endpoint, $scope.searchResultId).then(function (response) {\r\n if (response.data.successful) {\r\n $scope.cssClass = \"glyphicon-ok\";\r\n } else {\r\n $scope.cssClass = \"glyphicon-remove\";\r\n growl.error(response.data.message);\r\n }\r\n });\r\n };\r\n }\r\n}\r\n","//Can be used in an ng-repeat directive to call a function when the last element was rendered\n//We use it to mark the end of sorting / filtering so we can stop blocking the UI\n\nonFinishRender.$inject = [\"$timeout\"];\nangular\n .module('nzbhydraApp')\n .directive('onFinishRender', onFinishRender);\n\nfunction onFinishRender($timeout) {\n function linkFunction(scope, element, attr) {\n\n if (scope.$last === true) {\n console.log(\"Render finished\");\n // console.timeEnd(\"Presenting\");\n // console.timeEnd(\"searchall\");\n scope.$emit(\"onFinishRender\")\n }\n }\n\n return {\n link: linkFunction\n }\n}","//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly\nangular\n .module('nzbhydraApp')\n .directive('multiselectDropdown',\n\n dropdownMultiselectDirective\n );\n\nfunction dropdownMultiselectDirective() {\n return {\n scope: {\n selectedModel: '=',\n options: '=',\n settings: '=?',\n events: '=?'\n },\n transclude: {\n toggleDropdown: '?toggleDropdown'\n },\n templateUrl: 'static/html/directives/multiselect-dropdown.html',\n controller: [\"$scope\", \"$element\", \"$filter\", \"$document\", function dropdownMultiselectController($scope, $element, $filter, $document) {\n var $dropdownTrigger = $element.children()[0];\n\n var settings = {\n showSelectedValues: true,\n showSelectAll: true,\n showDeselectAll: true,\n noSelectedText: 'None selected'\n };\n var events = {\n onToggleItem: angular.noop\n };\n angular.extend(events, $scope.events || []);\n angular.extend(settings, $scope.settings || []);\n angular.extend($scope, {settings: settings, events: events});\n\n $scope.buttonText = \"\";\n if (settings.buttonText) {\n $scope.buttonText = settings.buttonText;\n } else {\n $scope.$watch(\"selectedModel\", function () {\n if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) {\n if ($scope.selectedModel.length === 0) {\n if ($scope.settings.noSelectedText) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = \"None selected\";\n }\n } else if ($scope.selectedModel.length === $scope.options.length) {\n $scope.buttonText = \"All selected\";\n } else {\n var selected = [];\n _.each($scope.options, function (x) {\n if ($scope.selectedModel.indexOf(x.id) > -1) {\n selected.push(x.label);\n }\n })\n $scope.buttonText = selected.join(\", \");\n }\n } else {\n if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = $scope.selectedModel.length + \" / \" + $scope.options.length + \" selected\";\n }\n }\n }, true);\n }\n $scope.open = false;\n\n $scope.toggleDropdown = function () {\n $scope.open = !$scope.open;\n };\n\n $scope.toggleItem = function (option) {\n var index = $scope.selectedModel.indexOf(option.id);\n var oldValue = index > -1;\n if (oldValue) {\n $scope.selectedModel.splice(index, 1);\n } else {\n $scope.selectedModel.push(option.id);\n }\n $scope.events.onToggleItem(option, !oldValue);\n };\n\n $scope.selectAll = function () {\n $scope.selectedModel = _.pluck($scope.options, \"id\");\n };\n\n $scope.deselectAll = function () {\n $scope.selectedModel.splice(0, $scope.selectedModel.length);\n };\n\n //Close when clicked outside\n\n $document.on('click', function (e) {\n function contains(collection, target) {\n var containsTarget = false;\n collection.some(function (object) {\n if (object === target) {\n containsTarget = true;\n return true;\n }\n return false;\n });\n return containsTarget;\n }\n\n if ($scope.open) {\n var target = e.target.parentElement;\n var parentFound = false;\n\n while (angular.isDefined(target) && target !== null && !parentFound) {\n if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) {\n if (target === $dropdownTrigger) {\n parentFound = true;\n }\n }\n target = target.parentElement;\n }\n\n if (!parentFound) {\n $scope.$apply(function () {\n $scope.open = false;\n });\n }\n }\n });\n\n\n }]\n\n }\n}","angular\r\n .module('nzbhydraApp').directive(\"keepFocus\", ['$timeout', function ($timeout) {\r\n /*\r\n Intended use:\r\n \r\n */\r\n return {\r\n restrict: 'A',\r\n require: 'ngModel',\r\n link: function ($scope, $element, attrs, ngModel) {\r\n\r\n ngModel.$parsers.unshift(function (value) {\r\n $timeout(function () {\r\n $element[0].focus();\r\n });\r\n return value;\r\n });\r\n\r\n }\r\n };\r\n}]);","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerStateSwitch', indexerStateSwitch);\r\n\r\nfunction indexerStateSwitch() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-state-switch.html',\r\n scope: {\r\n indexer: \"=\",\r\n handleWidth: \"@\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.value = $scope.indexer.state === \"ENABLED\";\r\n $scope.handleWidth = $scope.handleWidth || \"130px\";\r\n var initialized = false;\r\n\r\n function calculateTextAndColor() {\r\n if ($scope.indexer.state === \"DISABLED_USER\") {\r\n $scope.offText = \"Disabled by user\";\r\n $scope.offColor = \"default\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM_TEMPORARY\") {\r\n $scope.offText = \"Temporary disabled\";\r\n $scope.offColor = \"warning\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM\") {\r\n $scope.offText = \"Disabled by system\";\r\n $scope.offColor = \"danger\";\r\n }\r\n }\r\n\r\n calculateTextAndColor();\r\n\r\n $scope.onChange = function () {\r\n if (initialized) {\r\n //Skip on first call when initial value is set\r\n $scope.indexer.state = $scope.value ? \"ENABLED\" : \"DISABLED_USER\";\r\n calculateTextAndColor();\r\n }\r\n initialized = true;\r\n }\r\n }\r\n}","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerSelectionButton', indexerSelectionButton);\r\n\r\nfunction indexerSelectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-selection-button.html',\r\n scope: {\r\n selectedIndexers: \"=\",\r\n availableIndexers: \"=\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers,\r\n function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n }\r\n );\r\n\r\n $scope.invertSelection = function () {\r\n _.forEach($scope.availableIndexers, function (x) {\r\n var index = _.indexOf($scope.selectedIndexers, x.name);\r\n if (index === -1) {\r\n $scope.selectedIndexers.push(x.name);\r\n } else {\r\n $scope.selectedIndexers.splice(index, 1);\r\n }\r\n });\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, \"name\"));\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length);\r\n };\r\n\r\n function selectByPredicate(predicate) {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers,\r\n _.pluck(\r\n _.filter($scope.availableIndexers,\r\n predicate\r\n ), \"name\")\r\n );\r\n }\r\n\r\n $scope.reset = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.preselect;\r\n });\r\n };\r\n\r\n $scope.selectAllUsenet = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType !== \"TORZNAB\";\r\n });\r\n };\r\n\r\n $scope.selectAllTorrent = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n });\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('indexerInput', indexerInput);\r\n\r\nfunction indexerInput() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-input.html',\r\n scope: {\r\n indexer: \"=\",\r\n model: \"=\",\r\n onClick: \"=\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.isFocused = false;\r\n\r\n $scope.onFocus = function () {\r\n $scope.isFocused = true;\r\n };\r\n\r\n $scope.onBlur = function () {\r\n $scope.isFocused = false;\r\n };\r\n\r\n var expiryWarning;\r\n if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== \"Lifetime\") {\r\n var expiryDate = moment($scope.indexer.vipExpirationDate, \"YYYY-MM-DD\");\r\n if (expiryDate < moment()) {\r\n console.log(\"Expiry date reached for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access expired on \" + $scope.indexer.vipExpirationDate;\r\n } else if (expiryDate.subtract(7, 'days') < moment()) {\r\n console.log(\"Expiry date near for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access will expire on \" + $scope.indexer.vipExpirationDate;\r\n }\r\n }\r\n\r\n $scope.expiryWarning = expiryWarning;\r\n if ($scope.indexer.color !== null) {\r\n $scope.style = \"background-color: \" + $scope.indexer.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\r\n }\r\n }\r\n\r\n}\r\n\r\n","angular\n .module('nzbhydraApp')\n .directive('hydraupdates', hydraupdates);\n\nfunction hydraupdates() {\n controller.$inject = [\"$scope\", \"UpdateService\"];\n return {\n templateUrl: 'static/html/directives/updates.html',\n controller: controller\n };\n\n function controller($scope, UpdateService) {\n\n $scope.loadingPromise = UpdateService.getInfos().then(function (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.betaVersion = response.data.betaVersion;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.betaUpdateAvailable = response.data.betaUpdateAvailable;\n $scope.latestVersionIgnored = response.data.latestVersionIgnored;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.wrapperOutdated = response.data.wrapperOutdated;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n });\n\n UpdateService.getVersionHistory().then(function (response) {\n $scope.versionHistory = response.data;\n });\n\n\n $scope.update = function (version) {\n UpdateService.update(version);\n };\n\n $scope.showChangelog = function (version) {\n UpdateService.showChanges(version);\n };\n\n $scope.forceUpdate = function () {\n UpdateService.update($scope.latestVersion)\n };\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('hydraNews', hydraNews);\r\n\r\nfunction hydraNews() {\r\n controller.$inject = [\"$scope\", \"$http\"];\r\n return {\r\n templateUrl: \"static/html/directives/news.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http) {\r\n\r\n return $http.get(\"internalapi/news\").then(function (response) {\r\n $scope.news = response.data;\r\n });\r\n\r\n\r\n }\r\n}\r\n\r\n","\r\nLogModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"entry\"];\r\nescapeHtml.$inject = [\"$sanitize\"];angular\r\n .module('nzbhydraApp')\r\n .directive('hydralog', hydralog);\r\n\r\nfunction hydralog() {\r\n controller.$inject = [\"$scope\", \"$http\", \"$interval\", \"$uibModal\", \"$sce\", \"localStorageService\", \"growl\"];\r\n return {\r\n templateUrl: \"static/html/directives/log.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) {\r\n $scope.tailInterval = null;\r\n $scope.doUpdateLog = localStorageService.get(\"doUpdateLog\") !== null ? localStorageService.get(\"doUpdateLog\") : false;\r\n $scope.doTailLog = localStorageService.get(\"doTailLog\") !== null ? localStorageService.get(\"doTailLog\") : false;\r\n\r\n $scope.active = 0;\r\n $scope.currentJsonIndex = 0;\r\n $scope.hasMoreJsonLines = true;\r\n\r\n function getLog(index) {\r\n if ($scope.active === 0) {\r\n return $http.get(\"internalapi/debuginfos/jsonlogs\", {\r\n params: {\r\n offset: index,\r\n limit: 500\r\n }\r\n }).then(function (response) {\r\n var data = response.data;\r\n $scope.jsonLogLines = angular.fromJson(data.lines);\r\n $scope.hasMoreJsonLines = data.hasMore;\r\n });\r\n } else if ($scope.active === 1) {\r\n return $http.get(\"internalapi/debuginfos/currentlogfile\").then(function (response) {\r\n var data = response.data;\r\n $scope.log = $sce.trustAsHtml(data.replace(/&/g, \"&\")\r\n .replace(//g, \">\")\r\n .replace(/\"/g, \""\")\r\n .replace(/'/g, \"'\"));\r\n }, function (data) {\r\n growl.error(data)\r\n });\r\n } else if ($scope.active === 2) {\r\n return $http.get(\"internalapi/debuginfos/logfilenames\").then(function (response) {\r\n $scope.logfilenames = response.data;\r\n });\r\n }\r\n }\r\n\r\n $scope.logPromise = getLog();\r\n\r\n $scope.select = function (index) {\r\n $scope.active = index;\r\n $scope.update();\r\n };\r\n\r\n $scope.scrollToBottom = function () {\r\n document.getElementById(\"logfile\").scrollTop = 10000000;\r\n document.getElementById(\"logfile\").scrollTop = 100001000;\r\n };\r\n\r\n $scope.update = function () {\r\n getLog($scope.currentJsonIndex);\r\n if ($scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n };\r\n\r\n $scope.getOlderFormatted = function () {\r\n getLog($scope.currentJsonIndex + 500).then(function () {\r\n $scope.currentJsonIndex += 500;\r\n });\r\n\r\n };\r\n\r\n $scope.getNewerFormatted = function () {\r\n var index = Math.max($scope.currentJsonIndex - 500, 0);\r\n getLog(index);\r\n $scope.currentJsonIndex = index;\r\n };\r\n\r\n function startUpdateLogInterval() {\r\n $scope.tailInterval = $interval(function () {\r\n if ($scope.active === 1) {\r\n $scope.update();\r\n if ($scope.doTailLog && $scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n }\r\n }, 5000);\r\n }\r\n\r\n $scope.toggleUpdate = function (doUpdateLog) {\r\n $scope.doUpdateLog = doUpdateLog;\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n } else if ($scope.tailInterval !== null) {\r\n console.log(\"Cancelling\");\r\n $interval.cancel($scope.tailInterval);\r\n localStorageService.set(\"doTailLog\", false);\r\n $scope.doTailLog = false;\r\n }\r\n localStorageService.set(\"doUpdateLog\", $scope.doUpdateLog);\r\n };\r\n\r\n $scope.toggleTailLog = function () {\r\n localStorageService.set(\"doTailLog\", $scope.doTailLog);\r\n };\r\n\r\n $scope.openModal = function openModal(entry) {\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'log-entry.html',\r\n controller: LogModalInstanceCtrl,\r\n size: \"xl\",\r\n resolve: {\r\n entry: function () {\r\n return entry;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then();\r\n };\r\n\r\n $scope.$on('$destroy', function () {\r\n if ($scope.tailInterval !== null) {\r\n $interval.cancel($scope.tailInterval);\r\n }\r\n });\r\n\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('LogModalInstanceCtrl', LogModalInstanceCtrl);\r\n\r\nfunction LogModalInstanceCtrl($scope, $uibModalInstance, entry) {\r\n\r\n $scope.entry = entry;\r\n\r\n $scope.ok = function () {\r\n $uibModalInstance.dismiss();\r\n };\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatTimestamp', formatTimestamp);\r\n\r\nfunction formatTimestamp() {\r\n return function (date) {\r\n //1579392000\r\n //1579374757\r\n if (date === null || date === undefined) {\r\n return null;\r\n }\r\n if (date < 1979374757) {\r\n date *= 1000;\r\n }\r\n return moment(date).local().format(\"YYYY-MM-DD HH:mm\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('escapeHtml', escapeHtml);\r\n\r\nfunction escapeHtml($sanitize) {\r\n return function (text) {\r\n return $sanitize(text);\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatClassname', formatClassname);\r\n\r\nfunction formatClassname() {\r\n return function (fqn) {\r\n return fqn.substr(fqn.lastIndexOf(\".\") + 1);\r\n\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nNewsModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"news\"];\nWelcomeModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$state\", \"MigrationService\"];\nangular\n .module('nzbhydraApp')\n .directive('hydraChecksFooter', hydraChecksFooter);\n\nfunction hydraChecksFooter() {\n controller.$inject = [\"$scope\", \"UpdateService\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"ModalService\", \"growl\", \"NotificationService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/checks-footer.html',\n controller: controller\n };\n\n function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) {\n $scope.updateAvailable = false;\n $scope.checked = false;\n var welcomeIsBeingShown = false;\n\n $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin;\n\n $scope.$on(\"user:loggedIn\", function () {\n if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) {\n retrieveUpdateInfos();\n }\n });\n\n function checkForOutOfMemoryException() {\n GenericStorageService.get(\"outOfMemoryDetected\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Out of memory error detected\", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"outOfMemoryDetected\", false, false);\n }\n });\n }\n\n function checkForOpenToInternet() {\n GenericStorageService.get(\"showOpenToInternetWithoutAuth\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Security issue - open to internet\", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"showOpenToInternetWithoutAuth\", false, false);\n }\n });\n }\n\n console.log(\"Checking for below Java 17.\");\n\n function checkForJavaBelow17() {\n GenericStorageService.get(\"belowJava17\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n console.log(\"Java below 17\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Java version below 17\", 'You\\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"belowJava17\", false, false);\n }\n });\n }\n\n console.log(\"Checking for failed backup.\");\n\n function checkForFailedBackup() {\n GenericStorageService.get(\"FAILED_BACKUP\", false).then(function (response) {\n if (response.data !== \"\" && response.data && !response.data) {\n console.log(\"Failed backup detected\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Failed backup\", 'The creation of a backup file has failed. Error message: \\\"' + response.data.message + '.\"
                  For details please check the log around ' + response.data.time + '.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"FAILED_BACKUP\", false, null);\n }\n });\n }\n\n function checkForOutdatedWrapper() {\n $http.get(\"internalapi/updates/isDisplayWrapperOutdated\").then(function (response) {\n var data = response.data;\n if (data !== undefined && data !== null && data) {\n ModalService.open(\"Outdated wrappers detected\", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.

                  \\n' +\n ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder.
                  \\n' +\n ' For Windows these files are:\\n' +\n '
                    \\n' +\n '
                  • NZBHydra2.exe
                  • \\n' +\n '
                  • NZBHydra2 Console.exe
                  • \\n' +\n '
                  \\n' +\n ' For linux these files are:\\n' +\n '
                    \\n' +\n '
                  • nzbhydra2
                  • \\n' +\n '
                  • nzbhydra2wrapper.py
                  • \\n' +\n '
                  • nzbhydra2wrapperPy3.py
                  • \\n' +\n '
                  \\n' +\n ' Make sure to overwrite all of these files that already exist - you don\\'t need to update any files that aren\\'t already present.\\n' +\n '

                  \\n' +\n ' Afterwards start NZBHydra again.', {\n yes: {\n text: \"OK\",\n onYes: function () {\n $http.put(\"internalapi/updates/setOutdatedWrapperDetectedWarningShown\")\n }\n }\n }, undefined, \"left\");\n\n }\n });\n }\n\n if ($scope.mayUpdate) {\n retrieveUpdateInfos();\n checkForOutOfMemoryException();\n checkForOutdatedWrapper();\n checkForOpenToInternet();\n checkForJavaBelow17();\n checkForFailedBackup();\n }\n\n function retrieveUpdateInfos() {\n $scope.checked = true;\n UpdateService.getInfos().then(function (response) {\n if (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n $scope.showWhatsNewBanner = response.data.showWhatsNewBanner;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n\n\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n $scope.$emit(\"showAutomaticUpdateFooter\", $scope.automaticUpdateToNotice);\n } else {\n $scope.$emit(\"showUpdateFooter\", false);\n }\n });\n }\n\n $scope.update = function () {\n UpdateService.update($scope.latestVersion);\n };\n\n $scope.ignore = function () {\n UpdateService.ignore($scope.latestVersion);\n $scope.updateAvailable = false;\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n };\n\n $scope.showChangelog = function () {\n UpdateService.showChanges($scope.latestVersion);\n };\n\n $scope.showChangesFromAutomaticUpdate = function () {\n UpdateService.showChangesFromAutomaticUpdate();\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n };\n\n $scope.dismissChangesFromAutomaticUpdate = function () {\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n console.log(\"Dismissing showAutomaticUpdateFooter\");\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n });\n };\n\n function checkAndShowNews() {\n RequestsErrorHandler.specificallyHandled(function () {\n if (ConfigService.getSafe().showNews) {\n $http.get(\"internalapi/news/forcurrentversion\").then(function (response) {\n var data = response.data;\n if (data && data.length > 0) {\n $uibModal.open({\n templateUrl: 'static/html/news-modal.html',\n controller: NewsModalInstanceCtrl,\n size: \"lg\",\n resolve: {\n news: function () {\n return data;\n }\n }\n });\n $http.put(\"internalapi/news/saveshown\");\n }\n });\n }\n });\n }\n\n function checkExpiredIndexers() {\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== \"Lifetime\") {\n var expiryWarning;\n var expiryDate = moment(indexer.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access for indexer \" + indexer.name;\n if (expiryDate < moment()) {\n expiryWarning = messagePrefix + \" expired on \" + indexer.vipExpirationDate;\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n expiryWarning = messagePrefix + \" will expire on \" + indexer.vipExpirationDate;\n }\n if (expiryWarning) {\n console.log(expiryWarning);\n growl.warning(expiryWarning);\n }\n }\n });\n }\n\n function checkAndShowWelcome() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/welcomeshown\").then(function (response) {\n if (!response.data) {\n $http.put(\"internalapi/welcomeshown\");\n var promise = $uibModal.open({\n templateUrl: 'static/html/welcome-modal.html',\n controller: WelcomeModalInstanceCtrl,\n size: \"md\"\n });\n promise.opened.then(function () {\n welcomeIsBeingShown = true;\n });\n promise.closed.then(function () {\n welcomeIsBeingShown = false;\n });\n } else {\n if (HydraAuthService.getUserInfos().maySeeAdmin) {\n _.defer(checkAndShowNews);\n _.defer(checkExpiredIndexers);\n }\n }\n }, function () {\n console.log(\"Error while checking for welcome\")\n });\n });\n }\n\n checkAndShowWelcome();\n\n function showUnreadNotifications(unreadNotifications, stompClient) {\n if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) {\n growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true});\n for (var i = 0; i < unreadNotifications.length; i++) {\n if (unreadNotifications[i].id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, unreadNotifications[i].id);\n }\n return;\n }\n for (var j = 0; j < unreadNotifications.length; j++) {\n var notification = unreadNotifications[j];\n var body = notification.body.replace(\"\\n\", \"
                  \");\n switch (notification.messageType) {\n case \"INFO\":\n growl.info(body);\n break;\n case \"SUCCESS\":\n growl.success(body);\n break;\n case \"WARNING\":\n growl.warning(body);\n break;\n case \"FAILURE\":\n growl.danger(body);\n break;\n }\n if (notification.id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, notification.id);\n }\n }\n\n if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) {\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/notifications', function (message) {\n showUnreadNotifications(JSON.parse(message.body), stompClient);\n });\n });\n }\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl);\n\nfunction NewsModalInstanceCtrl($scope, $uibModalInstance, news) {\n $scope.news = news;\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl);\n\nfunction WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) {\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.startMigration = function () {\n $uibModalInstance.dismiss();\n MigrationService.migrate();\n };\n\n $scope.goToConfig = function () {\n $uibModalInstance.dismiss();\n $state.go(\"root.config.main\");\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('footer', footer);\n\nfunction footer() {\n controller.$inject = [\"$scope\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/footer.html',\n controller: controller\n };\n\n function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) {\n $scope.updateFooterBottom = 0;\n\n var safeConfig = bootstrapped.safeConfig;\n $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) {\n return x.enabled\n }).length > 0;\n $scope.showUpdateFooter = false;\n\n $scope.$on(\"showDownloaderStatus\", function (event, doShow) {\n $scope.showDownloaderStatus = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showUpdateFooter\", function (event, doShow) {\n $scope.showUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showAutomaticUpdateFooter\", function (event, doShow) {\n $scope.showAutomaticUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n\n function updateFooterBottom() {\n\n if ($scope.showDownloaderStatus) {\n if ($scope.showAutomaticUpdateFooter) {\n $scope.updateFooterBottom = 20;\n } else {\n $scope.updateFooterBottom = 38;\n }\n } else {\n $scope.updateFooterBottom = 0;\n }\n }\n\n function updatePaddingBottom() {\n var paddingBottom = 0;\n if ($scope.showDownloaderStatus) {\n paddingBottom += 30;\n }\n if ($scope.showUpdateFooter) {\n paddingBottom += 40;\n }\n $scope.paddingBottom = paddingBottom;\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-0\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-30\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-40\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-70\");\n var paddingBottomClass = \"padding-bottom-\" + paddingBottom;\n document.getElementById(\"wrap\").classList.add(paddingBottomClass);\n }\n\n updatePaddingBottom();\n\n updateFooterBottom();\n\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp').directive('focusOn', focusOn);\r\n\r\nfunction focusOn() {\r\n return directive;\r\n\r\n function directive(scope, elem, attr) {\r\n scope.$on('focusOn', function (e, name) {\r\n if (name === attr.focusOn) {\r\n elem[0].focus();\r\n }\r\n });\r\n }\r\n}\r\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('downloaderStatusFooter', downloaderStatusFooter);\n\nfunction downloaderStatusFooter() {\n controller.$inject = [\"$scope\", \"$http\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$interval\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/downloader-status-footer.html',\n controller: controller\n };\n\n function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) {\n\n var downloaderStatus;\n var updateInterval = null;\n console.log(\"websocket\");\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/downloaderStatus', function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n });\n stompClient.send(\"/app/connectDownloaderStatus\", function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n })\n });\n\n\n $scope.$emit(\"showDownloaderStatus\", true);\n var downloadRateCounter = 0;\n\n $scope.downloaderChart = {\n options: {\n chart: {\n type: 'stackedAreaChart',\n height: 35,\n width: 300,\n margin: {\n top: 5,\n right: 0,\n bottom: 0,\n left: 0\n },\n x: function (d) {\n return d.x;\n },\n y: function (d) {\n return d.y;\n },\n interactive: true,\n useInteractiveGuideline: false,\n transitionDuration: 0,\n showControls: false,\n showLegend: false,\n showValues: false,\n duration: 0,\n tooltip: {\n valueFormatter: function (d, i) {\n return d + \" kb/s\";\n },\n keyFormatter: function () {\n return \"\";\n },\n id: \"downloader-status-tooltip\"\n },\n css: \"float:right;\"\n }\n },\n data: [{values: [], key: \"Bla\", color: '#00a950'}],\n config: {\n refreshDataOnly: true,\n deepWatchDataDepth: 0,\n deepWatchData: false,\n deepWatchOptions: false\n }\n };\n\n function updateFooter() {\n if (downloaderStatus.lastUpdateForNow && updateInterval === null) {\n //Server will send no new status updates for a while because the last two retrieved statuses are the same.\n //We must still update the footer so that the graph doesn't stand still\n console.debug(\"Retrieved last update for now, starting update interval\");\n updateInterval = $interval(function () {\n //Just put the last known rate at the end to keep it going\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if (_.every($scope.downloaderChart.data[0].values, function (value) {\n return value === downloaderStatus.lastDownloadRate\n })) {\n //The bar has been filled with the latest known value, we can now stop until we get a new update\n console.debug(\"Filled the bar with last known value, stopping update interval\");\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n }, 1000);\n } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) {\n //New data is incoming, cancel interval\n console.debug(\"Got new update, stopping update interval\")\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n\n $scope.foo = downloaderStatus;\n $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo';\n $scope.foo.url = downloaderStatus.url;\n //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated\n var maxEntriesHistory = 200;\n if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) {\n //Not yet full, just fill up\n console.debug(\"Adding data, filling bar with initial values\")\n for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) {\n if (i >= downloaderStatus.downloadingRatesInKilobytes.length) {\n break;\n }\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]});\n }\n } else {\n console.debug(\"Adding data, moving bar\")\n //Remove first one, add to the end\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n }\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if ($scope.foo.state === \"DOWNLOADING\") {\n $scope.foo.buttonClass = \"play\";\n } else if ($scope.foo.state === \"PAUSED\") {\n $scope.foo.buttonClass = \"pause\";\n } else if ($scope.foo.state === \"OFFLINE\") {\n $scope.foo.buttonClass = \"off\";\n } else {\n $scope.foo.buttonClass = \"time\";\n }\n $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase();\n //Bad but without the state isn't updated\n $scope.$apply();\n }\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbzipButton', downloadNzbzipButton);\r\n\r\nfunction downloadNzbzipButton() {\r\n controller.$inject = [\"$scope\", \"growl\", \"$http\", \"FileDownloadService\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbzip-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n searchTitle: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope, growl, $http, FileDownloadService) {\r\n $scope.download = function () {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n var values = _.map($scope.searchResults, function (value) {\r\n return value.searchResultId;\r\n });\r\n var link = \"internalapi/nzbzip\";\r\n\r\n var searchTitle;\r\n if (angular.isDefined($scope.searchTitle)) {\r\n searchTitle = \" for \" + $scope.searchTitle.replace(\"[^a-zA-Z0-9.-]\", \"_\");\r\n } else {\r\n searchTitle = \"\";\r\n }\r\n var filename = \"NZBHydra NZBs\" + searchTitle + \".zip\";\r\n $http({method: \"post\", url: link, data: values}).then(function (response) {\r\n if (response.data.successful && response.data.zip !== null) {\r\n link = \"internalapi/nzbzipDownload\";\r\n FileDownloadService.downloadFile(link, filename, \"POST\", response.data.zipFilepath);\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n if (response.data.missedIds.length > 0) {\r\n growl.error(\"Unable to add \" + response.missedIds.length + \" out of \" + values.length + \" NZBs to ZIP\");\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n }\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbsButton', downloadNzbsButton);\r\n\r\nfunction downloadNzbsButton() {\r\n controller.$inject = [\"$scope\", \"$http\", \"NzbDownloadService\", \"ConfigService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbs-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, NzbDownloadService, ConfigService, growl) {\r\n\r\n $scope.downloaders = NzbDownloadService.getEnabledDownloaders();\r\n $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null;\r\n\r\n $scope.download = function (downloader) {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"NZB\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending torrent result to downloader\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were NZBs. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are torrent results which were skipped\");\r\n }\r\n\r\n var tos = _.map(searchResults, function (entry) {\r\n return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory}\r\n });\r\n\r\n NzbDownloadService.download(downloader, tos).then(function (response) {\r\n if (angular.isDefined(response.data)) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful) {\r\n if (response.data.message == null) {\r\n growl.info(\"Successfully added all NZBs\");\r\n } else {\r\n growl.warning(response.data.message);\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n growl.error(\"Error while adding NZBs\");\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n }\r\n }, function () {\r\n growl.error(\"Error while adding NZBs\");\r\n });\r\n }\r\n };\r\n\r\n $scope.sendToBlackhole = function () {\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"TORRENT\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending NZB result to black hole\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were torrents. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are NZB results which were skipped\");\r\n }\r\n var searchResultIds = _.pluck(searchResults, \"searchResultId\");\r\n $http.put(\"internalapi/saveTorrent\", searchResultIds).then(function (response) {\r\n if (response.data.successful) {\r\n growl.info(\"Successfully saved all torrents\");\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n });\r\n }\r\n\r\n }\r\n}\r\n\r\n","\r\nfreetextFilter.$inject = [\"DebugService\"];\r\nbooleanFilter.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp').directive(\"columnFilterWrapper\", columnFilterWrapper);\r\n\r\nfunction columnFilterWrapper() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: 'static/html/dataTable/columnFilterOuter.html',\r\n transclude: true,\r\n controllerAs: 'columnFilterWrapperCtrl',\r\n scope: {\r\n inline: \"@\"\r\n },\r\n bindToController: true,\r\n controller: controller,\r\n link: function (scope, element, attr, ctrl) {\r\n scope.element = element;\r\n }\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n var vm = this;\r\n\r\n vm.open = false;\r\n vm.isActive = false;\r\n\r\n vm.toggle = function () {\r\n vm.open = !vm.open;\r\n if (vm.open) {\r\n $scope.$broadcast(\"opened\");\r\n }\r\n };\r\n\r\n vm.clear = function () {\r\n if (vm.open) {\r\n $scope.$broadcast(\"clear\");\r\n }\r\n };\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive, open) {\r\n vm.open = open || false;\r\n vm.isActive = isActive;\r\n });\r\n\r\n DebugService.log(\"filter-wrapper\");\r\n }\r\n\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"freetextFilter\", freetextFilter);\r\n\r\nfunction freetextFilter(DebugService) {\r\n controller.$inject = [\"$scope\", \"focus\"];\r\n return {\r\n template: '',\r\n require: \"^columnFilterWrapper\",\r\n controllerAs: 'innerController',\r\n scope: {\r\n column: \"@\",\r\n onKey: \"@\",\r\n placeholder: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, focus) {\r\n $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper\r\n $scope.data = {};\r\n $scope.tooltip = $scope.tooltip || \"\";\r\n\r\n $scope.$on(\"opened\", function () {\r\n focus(\"freetext-filter-input\");\r\n });\r\n\r\n function emitFilterEvent(isOpen) {\r\n isOpen = $scope.inline || isOpen;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.data.filter,\r\n filterType: \"freetext\"\r\n }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen);\r\n }\r\n\r\n $scope.$on(\"clear\", function () {\r\n //Don't clear but close window (event is fired when clicked outside)\r\n emitFilterEvent(false);\r\n });\r\n\r\n $scope.onKeyUp = function (keyEvent) {\r\n if (keyEvent.which === 13 || $scope.onKey) {\r\n emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed\r\n }\r\n };\r\n DebugService.log(\"filter-freetext\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"checkboxesFilter\", checkboxesFilter);\r\n\r\nfunction checkboxesFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n controllerAs: 'checkboxesFilterController',\r\n scope: {\r\n column: \"@\",\r\n entries: \"<\",\r\n preselect: \"<\",\r\n showInvert: \"<\",\r\n isBoolean: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.selected = {\r\n entries: []\r\n };\r\n $scope.active = false;\r\n\r\n if ($scope.preselect) {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n }\r\n\r\n $scope.invert = function () {\r\n $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries);\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selected.entries.splice(0, $scope.selected.entries.length);\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.entries.length < $scope.entries.length;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: _.pluck($scope.selected.entries, \"id\"),\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selectAll();\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-checkboxes\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"booleanFilter\", booleanFilter);\r\n\r\nfunction booleanFilter(DebugService) {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n template: '',\r\n controllerAs: 'booleanFilterController',\r\n scope: {\r\n column: \"@\",\r\n options: \"<\",\r\n preselect: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope) {\r\n $scope.selected = {value: $scope.options[$scope.preselect].value};\r\n $scope.active = false;\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.value !== $scope.options[0].value;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.selected.value,\r\n filterType: \"boolean\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.value = true;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"boolean\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-boolean\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"timeFilter\", timeFilter);\r\n\r\nfunction timeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n selected: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n $scope.active = false;\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: {\r\n after: $scope.selected.afterDate,\r\n before: $scope.selected.beforeDate\r\n }, filterType: \"time\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.beforeDate = undefined;\r\n $scope.selected.afterDate = undefined;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"time\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-time\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"numberRangeFilter\", numberRangeFilter);\r\n\r\nfunction numberRangeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n min: \"<\",\r\n max: \"<\",\r\n addon: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n\r\n function apply() {\r\n $scope.active = $scope.filterValue.min || $scope.filterValue.max;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.filterValue,\r\n filterType: \"numberRange\"\r\n }, $scope.active)\r\n }\r\n\r\n $scope.clear = function () {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"numberRange\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n\r\n $scope.apply = function () {\r\n apply();\r\n };\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n apply();\r\n }\r\n };\r\n\r\n DebugService.log(\"filter-number\");\r\n }\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"columnSortable\", columnSortable);\r\n\r\nfunction columnSortable() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: \"static/html/dataTable/columnSortable.html\",\r\n transclude: true,\r\n scope: {\r\n sortMode: \"<\", //0: no sorting, 1: asc, 2: desc\r\n column: \"@\",\r\n reversed: \"<\",\r\n startMode: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n if (angular.isUndefined($scope.sortMode)) {\r\n $scope.sortMode = 0;\r\n }\r\n\r\n if (angular.isUndefined($scope.startMode)) {\r\n $scope.startMode = 1;\r\n }\r\n\r\n $scope.sortModel = {\r\n sortMode: $scope.sortMode,\r\n column: $scope.column,\r\n reversed: $scope.reversed,\r\n startMode: $scope.startMode,\r\n active: false\r\n };\r\n\r\n $scope.$on(\"newSortColumn\", function (event, column, sortMode) {\r\n $scope.sortModel.active = column === $scope.sortModel.column;\r\n if (column !== $scope.sortModel.column) {\r\n $scope.sortModel.sortMode = 0;\r\n } else {\r\n $scope.sortModel.sortMode = sortMode;\r\n }\r\n });\r\n\r\n $scope.sort = function () {\r\n if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) {\r\n $scope.sortModel.sortMode = $scope.sortModel.startMode;\r\n } else if ($scope.sortModel.sortMode === 1) {\r\n $scope.sortModel.sortMode = 2;\r\n } else {\r\n $scope.sortModel.sortMode = 1;\r\n }\r\n $scope.$emit(\"sort\", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed)\r\n };\r\n\r\n }\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('connectionTest', connectionTest);\r\n\r\nfunction connectionTest() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/connection-test.html',\r\n require: ['^type', '^data'],\r\n scope: {\r\n type: \"=\",\r\n id: \"=\",\r\n data: \"=\",\r\n downloader: \"=\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.message = \"\";\r\n\r\n\r\n var testButton = \"#button-test-connection\";\r\n var testMessage = \"#message-test-connection\";\r\n\r\n function showSuccess() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-danger\");\r\n angular.element(testButton).addClass(\"btn-success\");\r\n }\r\n\r\n function showError() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-success\");\r\n angular.element(testButton).addClass(\"btn-danger\");\r\n }\r\n\r\n $scope.testConnection = function () {\r\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\r\n var myInjector = angular.injector([\"ng\"]);\r\n var $http = myInjector.get(\"$http\");\r\n var url;\r\n var params;\r\n if ($scope.type === \"downloader\") {\r\n url = \"internalapi/test_downloader\";\r\n params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password};\r\n if ($scope.downloader === \"SABNZBD\") {\r\n params.apiKey = $scope.data.apiKey;\r\n params.url = $scope.data.url;\r\n } else {\r\n params.host = $scope.data.host;\r\n params.port = $scope.data.port;\r\n params.ssl = $scope.data.ssl;\r\n }\r\n } else if ($scope.data.type === \"newznab\") {\r\n url = \"internalapi/test_newznab\";\r\n params = {host: $scope.data.host, apiKey: $scope.data.apiKey};\r\n if (angular.isDefined($scope.data.username)) {\r\n params[\"username\"] = $scope.data.username;\r\n params[\"password\"] = $scope.data.password;\r\n }\r\n }\r\n $http.get(url, {params: params}).then(function (result) {\r\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\r\n if (result.successful) {\r\n angular.element(testMessage).text(\"\");\r\n showSuccess();\r\n } else {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n\r\n }, function () {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n ).finally(function () {\r\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\r\n })\r\n }\r\n\r\n }\r\n}\r\n\r\n","//Taken from https://github.com/IamAdamJowett/angular-click-outside\r\n\r\nclickOutside.$inject = [\"$document\", \"$parse\", \"$timeout\"];\r\nfunction childOf(/*child node*/c, /*parent node*/p) { //returns boolean\r\n while ((c = c.parentNode) && c !== p) ;\r\n return !!c;\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"clickOutside\", clickOutside);\r\n\r\n/**\r\n * @ngdoc directive\r\n * @name angular-click-outside.directive:clickOutside\r\n * @description Directive to add click outside capabilities to DOM elements\r\n * @requires $document\r\n * @requires $parse\r\n * @requires $timeout\r\n **/\r\nfunction clickOutside($document, $parse, $timeout) {\r\n return {\r\n restrict: 'A',\r\n link: function ($scope, elem, attr) {\r\n\r\n // postpone linking to next digest to allow for unique id generation\r\n $timeout(function () {\r\n var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [],\r\n fn;\r\n\r\n function eventHandler(e) {\r\n var i,\r\n element,\r\n r,\r\n id,\r\n classNames,\r\n l;\r\n\r\n // check if our element already hidden and abort if so\r\n if (angular.element(elem).hasClass(\"ng-hide\")) {\r\n return;\r\n }\r\n\r\n // if there is no click target, no point going on\r\n if (!e || !e.target) {\r\n return;\r\n }\r\n\r\n if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) {\r\n return;\r\n }\r\n var isChild = childOf(e.target, elem.context);\r\n if (isChild) {\r\n return;\r\n }\r\n // loop through the available elements, looking for classes in the class list that might match and so will eat\r\n for (element = e.target; element; element = element.parentNode) {\r\n // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru)\r\n if (element === elem[0]) {\r\n return;\r\n }\r\n\r\n // now we have done the initial checks, start gathering id's and classes\r\n id = element.id,\r\n classNames = element.className,\r\n l = classList.length;\r\n\r\n // Unwrap SVGAnimatedString classes\r\n if (classNames && classNames.baseVal !== undefined) {\r\n classNames = classNames.baseVal;\r\n }\r\n\r\n // if there are no class names on the element clicked, skip the check\r\n if (classNames || id) {\r\n\r\n // loop through the elements id's and classnames looking for exceptions\r\n for (i = 0; i < l; i++) {\r\n //prepare regex for class word matching\r\n r = new RegExp('\\\\b' + classList[i] + '\\\\b');\r\n\r\n // check for exact matches on id's or classes, but only if they exist in the first place\r\n if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) {\r\n // now let's exit out as it is an element that has been defined as being ignored for clicking outside\r\n return;\r\n }\r\n }\r\n }\r\n }\r\n\r\n // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute\r\n $timeout(function () {\r\n fn = $parse(attr['clickOutside']);\r\n fn($scope, {event: e});\r\n });\r\n }\r\n\r\n // if the devices has a touchscreen, listen for this event\r\n if (_hasTouch()) {\r\n $document.on('touchstart', eventHandler);\r\n }\r\n\r\n // still listen for the click event even if there is touch to cater for touchscreen laptops\r\n $document.on('click', eventHandler);\r\n\r\n // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around\r\n $scope.$on('$destroy', function () {\r\n if (_hasTouch()) {\r\n $document.off('touchstart', eventHandler);\r\n }\r\n\r\n $document.off('click', eventHandler);\r\n });\r\n\r\n /**\r\n * @description Private function to attempt to figure out if we are on a touch device\r\n * @private\r\n **/\r\n function _hasTouch() {\r\n // works on most browsers, IE10/11 and Surface\r\n return 'ontouchstart' in window || navigator.maxTouchPoints;\r\n }\r\n });\r\n }\r\n };\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('cfgFormEntry', cfgFormEntry);\r\n\r\nfunction cfgFormEntry() {\r\n return {\r\n templateUrl: 'static/html/directives/cfg-form-entry.html',\r\n require: [\"^title\", \"^cfg\"],\r\n scope: {\r\n title: \"@\",\r\n cfg: \"=\",\r\n help: \"@\",\r\n type: \"@?\",\r\n options: \"=?\"\r\n },\r\n controller: [\"$scope\", \"$element\", \"$attrs\", function ($scope, $element, $attrs) {\r\n $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text';\r\n $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : [];\r\n }]\r\n };\r\n}","angular\n .module('nzbhydraApp')\n .directive('hydrabackup', hydrabackup);\n\nfunction hydrabackup() {\n controller.$inject = [\"$scope\", \"BackupService\", \"Upload\", \"FileDownloadService\", \"$http\", \"RequestsErrorHandler\", \"growl\", \"RestartService\"];\n return {\n templateUrl: 'static/html/directives/backup.html',\n controller: controller\n };\n\n function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) {\n $scope.refreshBackupList = function () {\n BackupService.getBackupsList().then(function (backups) {\n $scope.backups = backups;\n });\n };\n\n $scope.refreshBackupList();\n\n $scope.uploadActive = false;\n\n\n $scope.createBackupFile = function () {\n $http.get(\"internalapi/backup/backuponly\", {params: {dontdownload: true}}).then(function () {\n $scope.refreshBackupList();\n });\n };\n $scope.createAndDownloadBackupFile = function () {\n FileDownloadService.downloadFile(\"internalapi/backup/backup\", \"nzbhydra-backup-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\", \"GET\").then(function () {\n $scope.refreshBackupList();\n });\n };\n\n $scope.uploadBackupFile = function (file, errFiles) {\n RequestsErrorHandler.specificallyHandled(function () {\n\n $scope.file = file;\n $scope.errFile = errFiles && errFiles[0];\n if (file) {\n $scope.uploadActive = true;\n file.upload = Upload.upload({\n url: 'internalapi/backup/restorefile',\n file: file\n });\n\n file.upload.then(function (response) {\n if (response.data.successful) {\n $scope.uploadActive = false;\n RestartService.startCountdown(\"Upload successful. Restarting for wrapper to restore data.\");\n } else {\n file.progress = 0;\n growl.error(response.data.message)\n }\n\n }, function (response) {\n growl.error(response.data.message)\n }, function (evt) {\n file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total));\n file.loaded = Math.floor(evt.loaded / 1024);\n file.total = Math.floor(evt.total / 1024);\n });\n }\n });\n };\n\n $scope.restoreFromFile = function (filename) {\n BackupService.restoreFromFile(filename).then(function () {\n RestartService.startCountdown(\"Extraction of backup successful. Restarting for wrapper to restore data.\");\n },\n function (response) {\n growl.error(response.data);\n })\n }\n\n }\n}\n\n","\naddableNzbs.$inject = [\"DebugService\"];angular\n .module('nzbhydraApp')\n .directive('addableNzbs', addableNzbs);\n\nfunction addableNzbs(DebugService) {\n controller.$inject = [\"$scope\", \"NzbDownloadService\"];\n return {\n templateUrl: 'static/html/directives/addable-nzbs.html',\n require: [],\n scope: {\n searchresult: \"<\",\n alwaysAsk: \"<\"\n },\n controller: controller\n };\n\n function controller($scope, NzbDownloadService) {\n $scope.alwaysAsk = $scope.alwaysAsk === \"true\";\n $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) {\n if ($scope.searchresult.downloadType !== \"NZB\") {\n return downloader.downloadType === $scope.searchresult.downloadType\n }\n return true;\n });\n }\n}\n","\r\naddableNzb.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp')\r\n .directive('addableNzb', addableNzb);\r\n\r\nfunction addableNzb(DebugService) {\r\n controller.$inject = [\"$scope\", \"NzbDownloadService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/addable-nzb.html',\r\n scope: {\r\n searchresult: \"=\",\r\n downloader: \"<\",\r\n alwaysAsk: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, NzbDownloadService, growl) {\r\n if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) {\r\n $scope.cssClass = \"fa fa-\" + $scope.downloader.iconCssClass.replace(\"fa-\", \"\").replace(\"fa \", \"\");\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd\" : \"nzbget\";\r\n }\r\n\r\n $scope.add = function () {\r\n var originalClass = $scope.cssClass;\r\n $scope.cssClass = \"nzb-spinning\";\r\n NzbDownloadService.download($scope.downloader, [{\r\n searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id,\r\n originalCategory: $scope.searchresult.originalCategory,\r\n mappedCategory: $scope.searchresult.category\r\n }], $scope.alwaysAsk).then(function (response) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-success\" : \"nzbget-success\";\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n $scope.cssClass = originalClass;\r\n }\r\n }, function () {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(\"An unexpected error occurred while trying to contact NZBHydra or add the NZB.\");\r\n })\r\n };\r\n }\r\n}","\nUpdateService.$inject = [\"$http\", \"growl\", \"blockUI\", \"RestartService\", \"RequestsErrorHandler\", \"$uibModal\", \"$timeout\"];\nUpdateModalInstanceCtrl.$inject = [\"$scope\", \"$http\", \"$interval\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('UpdateService', UpdateService);\n\nfunction UpdateService($http, growl, blockUI, RestartService, RequestsErrorHandler, $uibModal, $timeout) {\n\n var currentVersion;\n var latestVersion;\n var betaVersion;\n var updateAvailable;\n var betaUpdateAvailable;\n var latestVersionIgnored;\n var betaVersionsEnabled;\n var versionHistory;\n var updatedExternally;\n var automaticUpdateToNotice;\n\n\n return {\n update: update,\n showChanges: showChanges,\n getInfos: getInfos,\n getVersionHistory: getVersionHistory,\n ignore: ignore,\n showChangesFromAutomaticUpdate: showChangesFromAutomaticUpdate\n };\n\n function getInfos() {\n return RequestsErrorHandler.specificallyHandled(function () {\n return $http.get(\"internalapi/updates/infos\").then(\n function (response) {\n currentVersion = response.data.currentVersion;\n latestVersion = response.data.latestVersion;\n betaVersion = response.data.betaVersion;\n updateAvailable = response.data.updateAvailable;\n betaUpdateAvailable = response.data.betaUpdateAvailable;\n latestVersionIgnored = response.data.latestVersionIgnored;\n betaVersionsEnabled = response.data.betaVersionsEnabled;\n updatedExternally = response.data.updatedExternally;\n automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n return response;\n }, function () {\n\n }\n );\n });\n }\n\n function ignore(version) {\n return $http.put(\"internalapi/updates/ignore/\" + version).then(function (response) {\n return response;\n });\n }\n\n function getVersionHistory() {\n return $http.get(\"internalapi/updates/versionHistory\").then(function (response) {\n versionHistory = response.data;\n return response;\n });\n }\n\n function showChanges(version) {\n return $http.get(\"internalapi/updates/changesSince/\" + version).then(function (response) {\n var params = {\n size: \"lg\",\n templateUrl: \"static/html/changelog-modal.html\",\n resolve: {\n versionHistory: function () {\n return response.data;\n }\n },\n controller: function ($scope, $sce, $uibModalInstance, versionHistory) {\n $scope.versionHistory = versionHistory;\n\n $scope.ok = function () {\n $uibModalInstance.dismiss();\n };\n }\n };\n\n var modalInstance = $uibModal.open(params);\n modalInstance.result.then();\n });\n }\n\n function showChangesFromAutomaticUpdate() {\n return $http.get(\"internalapi/updates/automaticUpdateVersionHistory\").then(function (response) {\n var params = {\n size: \"lg\",\n templateUrl: \"static/html/changelog-modal.html\",\n resolve: {\n versionHistory: function () {\n return response.data;\n }\n },\n controller: function ($scope, $sce, $uibModalInstance, versionHistory) {\n $scope.versionHistory = versionHistory;\n\n $scope.ok = function () {\n $uibModalInstance.dismiss();\n };\n }\n };\n\n var modalInstance = $uibModal.open(params);\n modalInstance.result.then();\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n\n });\n });\n }\n\n\n function update(version) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/update-modal.html',\n controller: 'UpdateModalInstanceCtrl',\n size: \"md\",\n backdrop: 'static',\n keyboard: false\n });\n $http.put(\"internalapi/updates/installUpdate/\" + version).then(function () {\n //Handle like restart, ping application and wait\n //Perhaps save the version to which we want to update, ask later and see if they're equal. If not updating apparently failed...\n $timeout(function () {\n //Give user some time to read the last message\n RestartService.startCountdown(\"\");\n modalInstance.close();\n }, 2000);\n },\n function () {\n growl.info(\"An error occurred while updating. Please check the logs.\");\n modalInstance.close();\n });\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('UpdateModalInstanceCtrl', UpdateModalInstanceCtrl);\n\nfunction UpdateModalInstanceCtrl($scope, $http, $interval, RequestsErrorHandler) {\n $scope.messages = [];\n\n var interval = $interval(function () {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/updates/messages\").then(\n function (data) {\n $scope.messages = data.data;\n }\n );\n });\n },\n 200);\n\n $scope.$on('$destroy', function () {\n if (interval !== null) {\n $interval.cancel(interval);\n }\n });\n\n}\n","\nSystemController.$inject = [\"$scope\", \"$state\", \"activeTab\", \"simpleInfos\", \"$http\", \"growl\", \"RestartService\", \"MigrationService\", \"ConfigService\", \"NzbHydraControlService\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .controller('SystemController', SystemController);\n\nfunction SystemController($scope, $state, activeTab, simpleInfos, $http, growl, RestartService, MigrationService, ConfigService, NzbHydraControlService, RequestsErrorHandler) {\n\n $scope.activeTab = activeTab;\n $scope.foo = {\n csv: \"\",\n sql: \"\"\n };\n\n $scope.simpleInfos = simpleInfos;\n\n $scope.shutdown = function () {\n NzbHydraControlService.shutdown().then(function () {\n growl.info(\"Shutdown initiated. Cya!\");\n },\n function () {\n growl.info(\"Unable to send shutdown command.\");\n })\n };\n\n $scope.restart = function () {\n RestartService.restart();\n };\n\n $scope.reloadConfig = function () {\n ConfigService.reloadConfig().then(function () {\n growl.info(\"Successfully reloaded config. Some setting may need a restart to take effect.\")\n }, function (data) {\n growl.error(data.message);\n })\n };\n\n\n $scope.migrate = function () {\n MigrationService.migrate();\n };\n\n\n $scope.allTabs = [\n {\n active: false,\n state: 'root.system.control',\n name: \"Control\"\n },\n {\n active: false,\n state: 'root.system.updates',\n name: \"Updates\"\n },\n {\n active: false,\n state: 'root.system.log',\n name: \"Log\"\n },\n {\n active: false,\n state: 'root.system.tasks',\n name: \"Tasks\"\n },\n {\n active: false,\n state: 'root.system.backup',\n name: \"Backup\"\n },\n {\n active: false,\n state: 'root.system.bugreport',\n name: \"Bugreport / Debug\"\n },\n {\n active: false,\n state: 'root.system.news',\n name: \"News\"\n },\n {\n active: false,\n state: 'root.system.about',\n name: \"About\"\n }\n ];\n\n\n $scope.goToSystemState = function (index) {\n $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true});\n };\n\n $scope.downloadDebuggingInfos = function () {\n $scope.isBackupCreationAction = true;\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/createAndProvideZipAsBytes',\n responseType: 'arraybuffer'\n }).then(function (response, status, headers, config) {\n var a = document.createElement('a');\n var blob = new Blob([response.data], {'type': \"application/octet-stream\"});\n a.href = URL.createObjectURL(blob);\n a.download = \"nzbhydra-debuginfos-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\";\n\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n $scope.isBackupCreationAction = false;\n });\n };\n\n\n $scope.uploadDebuggingInfos = function () {\n $scope.isBackupCreationAction = true;\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/createAndUploadDebugInfos'\n }).then(function (response) {\n $scope.debugInfosUrl = 'URL with debug infos (will auto-delete on first download=: ' + response.data + '';\n $scope.isBackupCreationAction = false;\n }, function (response) {\n $scope.debugInfosUrl = response.data;\n $scope.isBackupCreationAction = false;\n });\n };\n\n $scope.logThreadDump = function () {\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/logThreadDump'\n });\n };\n\n $scope.executeSqlQuery = function () {\n $http.post('internalapi/debuginfos/executesqlquery', $scope.foo.sql).then(function (response) {\n if (response.data.successful) {\n $scope.foo.csv = response.data.message;\n } else {\n growl.error(response.data.message);\n }\n });\n };\n\n $scope.executeSqlUpdate = function () {\n $http.post('internalapi/debuginfos/executesqlupdate', $scope.foo.sql).then(function (response) {\n if (response.data.successful) {\n $scope.foo.csv = response.data.message + \" rows affected\";\n } else {\n growl.error(response.data.message);\n }\n });\n };\n\n\n $scope.cpuChart = {\n options: {\n chart:\n {\n type: 'lineChart',\n height: 450,\n margin: {\n top: 20,\n right: 20,\n bottom: 60,\n left: 65\n },\n x: function (d) {\n return d.time;\n },\n y: function (d) {\n return d.value;\n },\n xAxis: {\n axisLabel: 'Time',\n tickFormat: function (d) {\n return moment.unix(d).local().format(\"HH:mm:ss\");\n },\n showMaxMin: true\n },\n\n yAxis: {\n axisLabel: 'CPU %'\n },\n interactive: true\n }\n },\n data: []\n };\n\n function update() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/debuginfos/threadCpuUsage\", {ignoreLoadingBar: true}).then(function (response) {\n try {\n if (!response) {\n console.error(\"No CPU usage data from server\");\n return;\n }\n $scope.cpuChart.data = response.data;\n\n } catch (e) {\n console.error(e);\n clearInterval(timer);\n }\n },\n function () {\n console.error(\"Error while loading CPU usage data status\");\n clearInterval(timer);\n }\n );\n });\n }\n\n $scope.cpuChart.data = [];\n\n update();\n var timer = setInterval(function () {\n update();\n }, 5000);\n\n $scope.$on('$destroy', function () {\n if (timer !== null) {\n clearInterval(timer);\n }\n });\n\n}\n","\r\nStatsService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('StatsService', StatsService);\r\n\r\nfunction StatsService($http) {\r\n\r\n return {\r\n get: getStats,\r\n getDownloadHistory: getDownloadHistory,\r\n getNotificationHistory: getNotificationHistory\r\n };\r\n\r\n function getStats(after, before, includeDisabled, switchState) {\r\n var requestBody = {after: after, before: before, includeDisabled: includeDisabled};\r\n requestBody = _.extend(requestBody, switchState);\r\n return $http.post(\"internalapi/stats\", requestBody).then(function (response) {\r\n return response.data;\r\n });\r\n }\r\n\r\n function buildParams(pageNumber, limit, filterModel, sortModel) {\r\n var params = {page: pageNumber, limit: limit, filterModel: filterModel};\r\n if (angular.isUndefined(pageNumber)) {\r\n params.page = 1;\r\n }\r\n if (angular.isUndefined(limit)) {\r\n params.limit = 100;\r\n }\r\n if (angular.isUndefined(filterModel)) {\r\n params.filterModel = {}\r\n }\r\n if (!angular.isUndefined(sortModel)) {\r\n params.sortModel = sortModel;\r\n } else {\r\n params.sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n }\r\n return params;\r\n }\r\n\r\n function getDownloadHistory(pageNumber, limit, filterModel, sortModel) {\r\n var params = buildParams(pageNumber, limit, filterModel, sortModel);\r\n return $http.post(\"internalapi/history/downloads\", params).then(function (response) {\r\n return {\r\n nzbDownloads: response.data.content,\r\n totalDownloads: response.data.totalElements\r\n };\r\n\r\n });\r\n }\r\n\r\n function getNotificationHistory(pageNumber, limit, filterModel, sortModel) {\r\n var params = buildParams(pageNumber, limit, filterModel, sortModel);\r\n return $http.post(\"internalapi/history/notifications\", params).then(function (response) {\r\n return {\r\n notifications: response.data.content,\r\n totalNotifications: response.data.totalElements\r\n };\r\n\r\n });\r\n }\r\n\r\n}","\r\nStatsController.$inject = [\"$scope\", \"$filter\", \"StatsService\", \"blockUI\", \"localStorageService\", \"$timeout\", \"$window\", \"ConfigService\"];angular\r\n .module('nzbhydraApp')\r\n .controller('StatsController', StatsController);\r\n\r\nfunction StatsController($scope, $filter, StatsService, blockUI, localStorageService, $timeout, $window, ConfigService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n var initializingAfter = true;\r\n var initializingBefore = true;\r\n $scope.afterDate = moment().subtract(30, \"days\").toDate();\r\n $scope.beforeDate = moment().add(1, \"days\").toDate();\r\n var historyInfoTypeUserEnabled = ConfigService.getSafe().logging.historyUserInfoType === 'USERNAME' || ConfigService.getSafe().logging.historyUserInfoType === 'BOTH';\r\n var historyInfoTypeIpEnabled = ConfigService.getSafe().logging.historyUserInfoType === 'IP' || ConfigService.getSafe().logging.historyUserInfoType === 'BOTH';\r\n $scope.foo = {\r\n includeDisabledIndexersInStats: localStorageService.get(\"includeDisabledIndexersInStats\") !== null ? localStorageService.get(\"includeDisabledIndexersInStats\") : false,\r\n statsSwichState: localStorageService.get(\"statsSwitchState\") !== null ? localStorageService.get(\"statsSwitchState\") :\r\n {\r\n indexerApiAccessStats: true,\r\n avgIndexerUniquenessScore: true,\r\n avgResponseTimes: true,\r\n indexerDownloadShares: true,\r\n downloadsPerDayOfWeek: true,\r\n downloadsPerHourOfDay: true,\r\n searchesPerDayOfWeek: true,\r\n searchesPerHourOfDay: true,\r\n downloadsPerAgeStats: true,\r\n successfulDownloadsPerIndexer: true,\r\n downloadSharesPerUser: historyInfoTypeUserEnabled,\r\n searchSharesPerUser: historyInfoTypeIpEnabled,\r\n downloadSharesPerIp: true,\r\n searchSharesPerIp: true,\r\n userAgentSearchShares: true,\r\n userAgentDownloadShares: true\r\n }\r\n };\r\n localStorageService.set(\"statsSwitchState\", $scope.foo.statsSwichState);\r\n $scope.stats = {};\r\n\r\n updateStats();\r\n\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.toggleIncludeDisabledIndexers = function () {\r\n localStorageService.set(\"includeDisabledIndexersInStats\", $scope.foo.includeDisabledIndexersInStats);\r\n };\r\n\r\n $scope.onStatsSwitchToggle = function (statId) {\r\n localStorageService.set(\"statsSwitchState\", $scope.foo.statsSwichState);\r\n\r\n if ($scope.foo.statsSwichState[statId]) { //Stat was enabled, get only data for this stat\r\n updateStats(statId);\r\n }\r\n\r\n };\r\n\r\n $scope.refresh = function () {\r\n updateStats();\r\n };\r\n\r\n function updateStats(statId) {\r\n blockUI.start(\"Updating stats...\");\r\n var after = $scope.afterDate !== null ? $scope.afterDate : null;\r\n var before = $scope.beforeDate !== null ? $scope.beforeDate : null;\r\n var statsToRetrieve = {};\r\n if (angular.isDefined(statId)) {\r\n statsToRetrieve[statId] = true;\r\n } else {\r\n statsToRetrieve = $scope.foo.statsSwichState;\r\n }\r\n $scope.statsLoadingPromise = StatsService.get(after, before, $scope.foo.includeDisabledIndexersInStats, statsToRetrieve).then(function (stats) {\r\n $scope.setStats(stats);\r\n //Resize event is needed for the -perUsernameOrIp charts to be properly sized because nvd3 thinks the initial size is 0\r\n $timeout(function () {\r\n $window.dispatchEvent(new Event(\"resize\"));\r\n }, 500);\r\n });\r\n\r\n blockUI.reset();\r\n }\r\n\r\n $scope.$watch('beforeDate', function () {\r\n if (initializingBefore) {\r\n initializingBefore = false;\r\n } else {\r\n //updateStats();\r\n }\r\n });\r\n\r\n\r\n $scope.$watch('afterDate', function () {\r\n if (initializingAfter) {\r\n initializingAfter = false;\r\n } else {\r\n //updateStats();\r\n }\r\n });\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n //updateStats();\r\n }\r\n };\r\n\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n\r\n $scope.setStats = function (stats) {\r\n //Only update those stats that were calculated (because this might be an update when one stat has just been enabled)\r\n _.forEach(stats, function (value, key) {\r\n if (value !== null) {\r\n $scope.stats[key] = value;\r\n }\r\n });\r\n\r\n if ($scope.stats.avgResponseTimes) {\r\n $scope.avgResponseTimesChart = getChart(\"multiBarHorizontalChart\", $scope.stats.avgResponseTimes, \"indexer\", \"avgResponseTime\", \"\", \"Response time (ms)\");\r\n $scope.avgResponseTimesChart.options.chart.margin.left = 100;\r\n $scope.avgResponseTimesChart.options.chart.yAxis.rotateLabels = -30;\r\n $scope.avgResponseTimesChart.options.chart.height = Math.max($scope.stats.avgResponseTimes.length * 30, 350);\r\n }\r\n\r\n if ($scope.stats.downloadsPerHourOfDay) {\r\n $scope.downloadsPerHourOfDayChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerHourOfDay, \"hour\", \"count\", \"Hour of day\", 'Downloads');\r\n $scope.downloadsPerHourOfDayChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.downloadsPerDayOfWeek) {\r\n $scope.downloadsPerDayOfWeekChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerDayOfWeek, \"day\", \"count\", \"Day of week\", 'Downloads');\r\n $scope.downloadsPerDayOfWeekChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.searchesPerHourOfDay) {\r\n $scope.searchesPerHourOfDayChart = getChart(\"discreteBarChart\", $scope.stats.searchesPerHourOfDay, \"hour\", \"count\", \"Hour of day\", 'Searches');\r\n $scope.searchesPerHourOfDayChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.searchesPerDayOfWeek) {\r\n $scope.searchesPerDayOfWeekChart = getChart(\"discreteBarChart\", $scope.stats.searchesPerDayOfWeek, \"day\", \"count\", \"Day of week\", 'Searches');\r\n $scope.searchesPerDayOfWeekChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.downloadsPerAgeStats) {\r\n $scope.downloadsPerAgeChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerAgeStats.downloadsPerAge, \"age\", \"count\", \"Downloads per age\", 'Downloads');\r\n $scope.downloadsPerAgeChart.options.chart.xAxis.rotateLabels = 45;\r\n $scope.downloadsPerAgeChart.options.chart.showValues = false;\r\n }\r\n\r\n if ($scope.stats.successfulDownloadsPerIndexer) {\r\n $scope.successfulDownloadsPerIndexerChart = getChart(\"multiBarHorizontalChart\", $scope.stats.successfulDownloadsPerIndexer, \"indexerName\", \"percentSuccessful\", \"Indexer\", '% successful');\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.xAxis.rotateLabels = 90;\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.yAxis.tickFormat = function (d) {\r\n return $filter('number')(d, 0);\r\n };\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.valueFormat = function (d) {\r\n return $filter('number')(d, 0);\r\n };\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.showValues = true;\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.margin.left = 80;\r\n }\r\n\r\n if ($scope.stats.indexerDownloadShares) {\r\n $scope.indexerDownloadSharesChart = {\r\n options: {\r\n chart: {\r\n type: 'pieChart',\r\n height: 500,\r\n x: function (d) {\r\n return d.indexerName;\r\n },\r\n y: function (d) {\r\n return d.share;\r\n },\r\n showLabels: true,\r\n donut: true,\r\n donutRatio: 0.35,\r\n duration: 500,\r\n labelThreshold: 0.03,\r\n labelSunbeamLayout: true,\r\n tooltip: {\r\n valueFormatter: function (d, i) {\r\n return $filter('number')(d, 2) + \"%\";\r\n }\r\n },\r\n legend: {\r\n margin: {\r\n top: 5,\r\n right: 35,\r\n bottom: 5,\r\n left: 0\r\n }\r\n }\r\n }\r\n },\r\n data: $scope.stats.indexerDownloadShares\r\n };\r\n $scope.indexerDownloadSharesChart.options.chart.height = Math.min(Math.max(($scope.foo.includeDisabledIndexersInStats ? $scope.stats.numberOfConfiguredIndexers : $scope.stats.numberOfEnabledIndexers) * 40, 350), 900);\r\n }\r\n\r\n function getSharesPieChart(data, height, xValue, yValue) {\r\n return {\r\n options: {\r\n chart: {\r\n type: 'pieChart',\r\n height: height,\r\n x: function (d) {\r\n return d[xValue];\r\n },\r\n y: function (d) {\r\n return d[yValue];\r\n },\r\n showLabels: true,\r\n donut: true,\r\n donutRatio: 0.35,\r\n duration: 500,\r\n labelThreshold: 0.03,\r\n labelsOutside: true,\r\n //labelType: \"percent\",\r\n labelSunbeamLayout: true,\r\n tooltip: {\r\n valueFormatter: function (d, i) {\r\n return $filter('number')(d, 2) + \"%\";\r\n }\r\n },\r\n legend: {\r\n margin: {\r\n top: 5,\r\n right: 35,\r\n bottom: 5,\r\n left: 0\r\n }\r\n }\r\n }\r\n },\r\n data: data\r\n };\r\n }\r\n\r\n if ($scope.stats.searchSharesPerIp !== null) {\r\n $scope.downloadSharesPerIpChart = getSharesPieChart($scope.stats.downloadSharesPerIp, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerIpChart !== null) {\r\n $scope.searchSharesPerIpChart = getSharesPieChart($scope.stats.searchSharesPerIp, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerUser !== null) {\r\n $scope.downloadSharesPerUserChart = getSharesPieChart($scope.stats.downloadSharesPerUser, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerUserChart !== null) {\r\n $scope.searchSharesPerUserChart = getSharesPieChart($scope.stats.searchSharesPerUser, 300, \"key\", \"percentage\");\r\n }\r\n\r\n if ($scope.stats.userAgentSearchShares) {\r\n $scope.userAgentSearchSharesChart = getSharesPieChart($scope.stats.userAgentSearchShares, 300, \"userAgent\", \"percentage\");\r\n $scope.userAgentSearchSharesChart.options.chart.legend.margin.bottom = 25;\r\n }\r\n if ($scope.stats.userAgentDownloadShares) {\r\n $scope.userAgentDownloadSharesChart = getSharesPieChart($scope.stats.userAgentDownloadShares, 300, \"userAgent\", \"percentage\");\r\n $scope.userAgentDownloadSharesChart.options.chart.legend.margin.bottom = 25;\r\n }\r\n\r\n };\r\n\r\n function getChart(chartType, values, xKey, yKey, xAxisLabel, yAxisLabel) {\r\n return {\r\n options: {\r\n chart: {\r\n type: chartType,\r\n height: 350,\r\n margin: {\r\n top: 20,\r\n right: 20,\r\n bottom: 100,\r\n left: 50\r\n },\r\n x: function (d) {\r\n return d[xKey];\r\n },\r\n y: function (d) {\r\n return d[yKey];\r\n },\r\n showValues: true,\r\n valueFormat: function (d) {\r\n return d;\r\n },\r\n color: function () {\r\n return \"red\"\r\n },\r\n showControls: false,\r\n showLegend: false,\r\n duration: 100,\r\n xAxis: {\r\n axisLabel: xAxisLabel,\r\n tickFormat: function (d) {\r\n return d;\r\n },\r\n rotateLabels: 30,\r\n showMaxMin: false,\r\n color: function () {\r\n return \"white\"\r\n }\r\n },\r\n yAxis: {\r\n axisLabel: yAxisLabel,\r\n axisLabelDistance: -10,\r\n tickFormat: function (d) {\r\n return d;\r\n }\r\n },\r\n tooltip: {\r\n enabled: false\r\n },\r\n zoom: {\r\n enabled: true,\r\n scaleExtent: [1, 10],\r\n useFixedDomain: false,\r\n useNiceScale: false,\r\n horizontalOff: false,\r\n verticalOff: true,\r\n unzoomEventType: 'dblclick.zoom'\r\n }\r\n }\r\n }, data: [{\r\n \"key\": \"doesntmatter\",\r\n \"bar\": true,\r\n \"values\": values\r\n }]\r\n };\r\n }\r\n}\r\n","//\nSearchService.$inject = [\"$http\"];\nangular\n .module('nzbhydraApp')\n .factory('SearchService', SearchService);\n\nfunction SearchService($http) {\n\n\n var lastExecutedQuery;\n var lastExecutedSearchRequestParameters;\n var lastResults;\n var modalInstance;\n\n return {\n search: search,\n getLastResults: getLastResults,\n loadMore: loadMore,\n shortcutSearch: shortcutSearch,\n getModalInstance: getModalInstance,\n setModalInstance: setModalInstance,\n };\n\n function getModalInstance() {\n return modalInstance;\n }\n\n function setModalInstance(mi) {\n modalInstance = mi;\n }\n\n function search(searchRequestId, category, query, metaData, season, episode, minsize, maxsize, minage, maxage, indexers, mode) {\n // console.time(\"search\");\n var uri = new URI(\"internalapi/search\");\n var searchRequestParameters = {};\n searchRequestParameters.searchRequestId = searchRequestId;\n searchRequestParameters.query = query;\n searchRequestParameters.minsize = minsize;\n searchRequestParameters.maxsize = maxsize;\n searchRequestParameters.minage = minage;\n searchRequestParameters.maxage = maxage;\n searchRequestParameters.category = category;\n searchRequestParameters.mode = mode;\n if (!angular.isUndefined(indexers) && indexers !== null) {\n searchRequestParameters.indexers = indexers.split(\",\");\n }\n\n if (metaData) {\n searchRequestParameters.title = metaData.title;\n if (category.indexOf(\"Movies\") > -1 || (category.indexOf(\"20\") === 0) || mode === \"movie\") {\n searchRequestParameters.tmdbId = metaData.tmdbId;\n searchRequestParameters.imdbId = metaData.imdbId;\n } else if (category.indexOf(\"TV\") > -1 || (category.indexOf(\"50\") === 0) || mode === \"tvsearch\") {\n searchRequestParameters.tvdbId = metaData.tvdbId;\n searchRequestParameters.imdbId = metaData.imdbId;\n searchRequestParameters.tvrageId = metaData.rid;\n searchRequestParameters.tvmazeId = metaData.tvmazeId;\n searchRequestParameters.season = season;\n searchRequestParameters.episode = episode;\n }\n }\n\n lastExecutedQuery = uri;\n lastExecutedSearchRequestParameters = searchRequestParameters;\n return $http.post(uri.toString(), searchRequestParameters).then(processData);\n }\n\n function loadMore(offset, limit, loadAll) {\n lastExecutedSearchRequestParameters.offset = offset;\n lastExecutedSearchRequestParameters.limit = limit;\n lastExecutedSearchRequestParameters.loadAll = angular.isDefined(loadAll) ? loadAll : false;\n\n return $http.post(lastExecutedQuery.toString(), lastExecutedSearchRequestParameters).then(processData);\n }\n\n function shortcutSearch(searchRequestId) {\n return $http.post(\"internalapi/shortcutSearch/\" + searchRequestId);\n }\n\n function processData(response) {\n var searchResults = response.data.searchResults;\n var indexerSearchMetaDatas = response.data.indexerSearchMetaDatas;\n var numberOfAvailableResults = response.data.numberOfAvailableResults;\n var numberOfRejectedResults = response.data.numberOfRejectedResults;\n var numberOfDuplicateResults = response.data.numberOfDuplicateResults;\n var numberOfAcceptedResults = response.data.numberOfAcceptedResults;\n var numberOfProcessedResults = response.data.numberOfProcessedResults;\n var rejectedReasonsMap = response.data.rejectedReasonsMap;\n var notPickedIndexersWithReason = response.data.notPickedIndexersWithReason;\n\n lastResults = {\n \"searchResults\": searchResults,\n \"indexerSearchMetaDatas\": indexerSearchMetaDatas,\n \"numberOfAvailableResults\": numberOfAvailableResults,\n \"numberOfAcceptedResults\": numberOfAcceptedResults,\n \"numberOfRejectedResults\": numberOfRejectedResults,\n \"numberOfProcessedResults\": numberOfProcessedResults,\n \"numberOfDuplicateResults\": numberOfDuplicateResults,\n \"rejectedReasonsMap\": rejectedReasonsMap,\n \"notPickedIndexersWithReason\": notPickedIndexersWithReason\n\n };\n // console.timeEnd(\"searchonly\");\n return lastResults;\n }\n\n function getLastResults() {\n return lastResults;\n }\n}","\nSearchResultsController.$inject = [\"$stateParams\", \"$scope\", \"$q\", \"$timeout\", \"$document\", \"blockUI\", \"growl\", \"localStorageService\", \"SearchService\", \"ConfigService\", \"CategoriesService\", \"DebugService\", \"GenericStorageService\", \"ModalService\", \"$uibModal\"];angular\n .module('nzbhydraApp')\n .controller('SearchResultsController', SearchResultsController);\n\n//SearchResultsController.$inject = ['blockUi'];\nfunction SearchResultsController($stateParams, $scope, $q, $timeout, $document, blockUI, growl, localStorageService, SearchService, ConfigService, CategoriesService, DebugService, GenericStorageService, ModalService, $uibModal) {\n // console.time(\"Presenting\");\n DebugService.log(\"foobar\");\n $scope.limitTo = ConfigService.getSafe().searching.loadLimitInternal;\n $scope.offset = 0;\n $scope.allowZipDownload = ConfigService.getSafe().downloading.fileDownloadAccessType === 'PROXY';\n\n var indexerColors = {};\n\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n indexerColors[indexer.name] = indexer.color;\n });\n\n //Handle incoming data\n\n $scope.indexersearches = SearchService.getLastResults().indexerSearchMetaDatas;\n $scope.notPickedIndexersWithReason = [];\n _.forEach(SearchService.getLastResults().notPickedIndexersWithReason, function (k, v) {\n $scope.notPickedIndexersWithReason.push({\"indexer\": v, \"reason\": k});\n });\n $scope.indexerResultsInfo = {}; //Stores information about the indexerName's searchResults like how many we already retrieved\n $scope.groupExpanded = {};\n $scope.selected = [];\n if ($stateParams.title) {\n $scope.searchTitle = $stateParams.title;\n } else if ($stateParams.query) {\n $scope.searchTitle = $stateParams.query;\n } else {\n $scope.searchTitle = undefined;\n }\n\n $scope.selectedIds = _.map($scope.selected, function (value) {\n return value.searchResultId;\n });\n\n //For shift clicking results\n $scope.lastClickedValue = null;\n\n var allSearchResults = [];\n var sortModel = {};\n $scope.filterModel = {};\n\n\n $scope.filterButtonsModel = {\n source: {},\n quality: {},\n other: {},\n custom: {}\n };\n $scope.customFilterButtons = [];\n\n $scope.filterButtonsModelMap = {\n tv: ['hdtv'],\n camts: ['cam', 'ts'],\n web: ['webrip', 'web-dl', 'webdl'],\n dvd: ['dvd'],\n bluray: ['bluray', 'blu-ray']\n };\n _.each(ConfigService.getSafe().searching.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n $scope.filterButtonsModelMap[displayName] = split1[1].split(\",\");\n $scope.customFilterButtons.push(displayName);\n });\n _.each(ConfigService.getSafe().searching.preselectQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"|\");\n var category = split1[0];\n var id = split1[1];\n if (category !== 'source' || $scope.isShowFilterButtonsVideo) {\n $scope.filterButtonsModel[category][id] = true;\n }\n })\n\n $scope.numberOfFilteredResults = 0;\n\n\n if ($stateParams.sortby !== undefined) {\n $stateParams.sortby = $stateParams.sortby.toLowerCase();\n sortModel = {};\n sortModel.reversed = false;\n if ($stateParams.sortby === \"title\") {\n sortModel.column = \"title\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"indexer\") {\n sortModel.column = \"indexer\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"category\") {\n sortModel.column = \"category\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"size\") {\n sortModel.column = \"size\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"details\") {\n sortModel.column = \"grabs\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"age\") {\n sortModel.column = \"epoch\";\n sortModel.reversed = true;\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 2;\n } else {\n sortModel.sortMode = 1;\n }\n }\n\n\n } else if (localStorageService.get(\"sorting\") !== null) {\n sortModel = localStorageService.get(\"sorting\");\n } else {\n sortModel = {\n column: \"epoch\",\n sortMode: 2,\n reversed: false\n };\n }\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode, sortModel.reversed);\n }, 10);\n\n\n $scope.foo = {\n indexerStatusesExpanded: localStorageService.get(\"indexerStatusesExpanded\") !== null ? localStorageService.get(\"indexerStatusesExpanded\") : false,\n duplicatesDisplayed: localStorageService.get(\"duplicatesDisplayed\") !== null ? localStorageService.get(\"duplicatesDisplayed\") : false,\n groupTorrentAndNewznabResults: localStorageService.get(\"groupTorrentAndNewznabResults\") !== null ? localStorageService.get(\"groupTorrentAndNewznabResults\") : false,\n sumGrabs: localStorageService.get(\"sumGrabs\") !== null ? localStorageService.get(\"sumGrabs\") : true,\n scrollToResults: localStorageService.get(\"scrollToResults\") !== null ? localStorageService.get(\"scrollToResults\") : true,\n showCovers: localStorageService.get(\"showCovers\") !== null ? localStorageService.get(\"showCovers\") : true,\n groupEpisodes: localStorageService.get(\"groupEpisodes\") !== null ? localStorageService.get(\"groupEpisodes\") : true,\n expandGroupsByDefault: localStorageService.get(\"expandGroupsByDefault\") !== null ? localStorageService.get(\"expandGroupsByDefault\") : false,\n showDownloadedIndicator: localStorageService.get(\"showDownloadedIndicator\") !== null ? localStorageService.get(\"showDownloadedIndicator\") : true,\n hideAlreadyDownloadedResults: localStorageService.get(\"hideAlreadyDownloadedResults\") !== null ? localStorageService.get(\"hideAlreadyDownloadedResults\") : true,\n showResultsAsZipButton: localStorageService.get(\"showResultsAsZipButton\") !== null ? localStorageService.get(\"showResultsAsZipButton\") : true,\n alwaysShowTitles: localStorageService.get(\"alwaysShowTitles\") !== null ? localStorageService.get(\"alwaysShowTitles\") : true\n };\n\n\n $scope.isShowFilterButtons = ConfigService.getSafe().searching.showQuickFilterButtons;\n $scope.isShowFilterButtonsVideo = $scope.isShowFilterButtons && ($stateParams.category.toLowerCase().indexOf(\"tv\") > -1 || $stateParams.category.toLowerCase().indexOf(\"movie\") > -1 || ConfigService.getSafe().searching.alwaysShowQuickFilterButtons);\n $scope.isShowCustomFilterButtons = ConfigService.getSafe().searching.customQuickFilterButtons.length > 0;\n\n $scope.shared = {\n isGroupEpisodes: $scope.foo.groupEpisodes && $stateParams.category.toLowerCase().indexOf(\"tv\") > -1 && $stateParams.episode === undefined,\n expandGroupsByDefault: $scope.foo.expandGroupsByDefault,\n showDownloadedIndicator: $scope.foo.showDownloadedIndicator,\n hideAlreadyDownloadedResults: $scope.foo.hideAlreadyDownloadedResults,\n alwaysShowTitles: $scope.foo.alwaysShowTitles\n };\n\n if ($scope.shared.isGroupEpisodes) {\n GenericStorageService.get(\"isGroupEpisodesHelpShown\", true).then(function (response) {\n if (!response.data) {\n ModalService.open(\"Sorting of TV episodes\", 'When searching in the TV categories results are automatically grouped by episodes. This makes it easier to download one episode each. You can disable this feature any time using the \"Display options\" button to the upper left.', {\n yes: {\n text: \"OK\"\n }\n });\n GenericStorageService.put(\"isGroupEpisodesHelpShown\", true, true);\n }\n\n })\n }\n\n $scope.loadMoreEnabled = false;\n $scope.totalAvailableUnknown = false;\n $scope.expandedTitlegroups = [];\n $scope.optionsOptions = [\n {id: \"duplicatesDisplayed\", label: \"Show duplicate display triggers\"},\n {id: \"groupTorrentAndNewznabResults\", label: \"Group torrent and usenet results\"},\n {id: \"sumGrabs\", label: \"Use sum of grabs / seeders for filtering / sorting of groups\"},\n {id: \"scrollToResults\", label: \"Scroll to results when finished\"},\n {id: \"showCovers\", label: \"Show movie covers in results\"},\n {id: \"groupEpisodes\", label: \"Group TV results by season/episode\"},\n {id: \"expandGroupsByDefault\", label: \"Expand groups by default\"},\n {id: \"alwaysShowTitles\", label: \"Always show result titles (even when grouped)\"},\n {id: \"showDownloadedIndicator\", label: \"Show already downloaded indicator\"},\n {id: \"hideAlreadyDownloadedResults\", label: \"Hide already downloaded results\"}\n ];\n if ($scope.allowZipDownload) {\n $scope.optionsOptions.push({id: \"showResultsAsZipButton\", label: \"Show button to download results as ZIP\"});\n }\n $scope.optionsSelectedModel = [];\n for (var key in $scope.optionsOptions) {\n if ($scope.foo[$scope.optionsOptions[key][\"id\"]]) {\n $scope.optionsSelectedModel.push($scope.optionsOptions[key].id);\n }\n }\n\n $scope.optionsExtraSettings = {\n showSelectAll: false,\n showDeselectAll: false,\n buttonText: \"Display options\"\n };\n\n $scope.optionsEvents = {\n onToggleItem: function (item, newValue) {\n if (item.id === \"duplicatesDisplayed\") {\n toggleDuplicatesDisplayed(newValue);\n } else if (item.id === \"groupTorrentAndNewznabResults\") {\n toggleGroupTorrentAndNewznabResults(newValue);\n } else if (item.id === \"sumGrabs\") {\n toggleSumGrabs(newValue);\n } else if (item.id === \"scrollToResults\") {\n toggleScrollToResults(newValue);\n } else if (item.id === \"showCovers\") {\n toggleShowCovers(newValue);\n } else if (item.id === \"groupEpisodes\") {\n toggleGroupEpisodes(newValue);\n } else if (item.id === \"expandGroupsByDefault\") {\n toggleExpandGroups(newValue);\n } else if (item.id === \"showDownloadedIndicator\") {\n toggleDownloadedIndicator(newValue);\n } else if (item.id === \"hideAlreadyDownloadedResults\") {\n toggleHideAlreadyDownloadedResults(newValue);\n } else if (item.id === \"showResultsAsZipButton\") {\n toggleShowResultsAsZipButton(newValue);\n } else if (item.id === \"alwaysShowTitles\") {\n toggleAlwaysShowTitles(newValue);\n }\n }\n };\n\n function toggleDuplicatesDisplayed(value) {\n localStorageService.set(\"duplicatesDisplayed\", value);\n $scope.$broadcast(\"duplicatesDisplayed\", value);\n $scope.foo.duplicatesDisplayed = value;\n $scope.shared.duplicatesDisplayed = value;\n }\n\n function toggleGroupTorrentAndNewznabResults(value) {\n localStorageService.set(\"groupTorrentAndNewznabResults\", value);\n $scope.foo.groupTorrentAndNewznabResults = value;\n $scope.shared.groupTorrentAndNewznabResults = value;\n blockAndUpdate();\n }\n\n function toggleSumGrabs(value) {\n localStorageService.set(\"sumGrabs\", value);\n $scope.foo.sumGrabs = value;\n $scope.shared.sumGrabs = value;\n blockAndUpdate();\n }\n\n function toggleScrollToResults(value) {\n localStorageService.set(\"scrollToResults\", value);\n $scope.foo.scrollToResults = value;\n $scope.shared.scrollToResults = value;\n }\n\n function toggleShowCovers(value) {\n localStorageService.set(\"showCovers\", value);\n $scope.foo.showCovers = value;\n $scope.shared.showCovers = value;\n $scope.$broadcast(\"toggleShowCovers\", value);\n }\n\n function toggleGroupEpisodes(value) {\n localStorageService.set(\"groupEpisodes\", value);\n $scope.shared.isGroupEpisodes = value;\n $scope.foo.isGroupEpisodes = value;\n blockAndUpdate();\n }\n\n function toggleExpandGroups(value) {\n localStorageService.set(\"expandGroupsByDefault\", value);\n $scope.shared.isExpandGroupsByDefault = value;\n $scope.foo.isExpandGroupsByDefault = value;\n blockAndUpdate();\n }\n\n function toggleDownloadedIndicator(value) {\n localStorageService.set(\"showDownloadedIndicator\", value);\n $scope.shared.showDownloadedIndicator = value;\n $scope.foo.showDownloadedIndicator = value;\n blockAndUpdate();\n }\n\n function toggleHideAlreadyDownloadedResults(value) {\n localStorageService.set(\"hideAlreadyDownloadedResults\", value);\n $scope.foo.hideAlreadyDownloadedResults = value;\n blockAndUpdate();\n }\n\n function toggleShowResultsAsZipButton(value) {\n localStorageService.set(\"showResultsAsZipButton\", value);\n $scope.shared.showResultsAsZipButton = value;\n $scope.foo.showResultsAsZipButton = value;\n }\n\n function toggleAlwaysShowTitles(value) {\n localStorageService.set(\"alwaysShowTitles\", value);\n $scope.shared.alwaysShowTitles = value;\n $scope.foo.alwaysShowTitles = value;\n $scope.$broadcast(\"toggleAlwaysShowTitles\", value);\n }\n\n\n $scope.indexersForFiltering = [];\n _.forEach($scope.indexersearches, function (indexer) {\n $scope.indexersForFiltering.push({label: indexer.indexerName, id: indexer.indexerName})\n });\n $scope.categoriesForFiltering = [];\n _.forEach(CategoriesService.getWithoutAll(), function (category) {\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\n });\n _.forEach($scope.indexersearches, function (ps) {\n $scope.indexerResultsInfo[ps.indexerName.toLowerCase()] = {loadedResults: ps.loaded_results};\n });\n\n setDataFromSearchResult(SearchService.getLastResults(), []);\n $scope.$emit(\"searchResultsShown\");\n\n if (!SearchService.getLastResults().searchResults || SearchService.getLastResults().searchResults.length === 0 || $scope.allResultsFiltered || $scope.numberOfAcceptedResults === 0) {\n //Close modal instance because no search results will be rendered that could trigger the closing\n console.log(\"CLosing status window\");\n SearchService.getModalInstance().close();\n $scope.doShowResults = true;\n } else {\n console.log(\"Will leave the closing of the status window to finishRendering. # of search results: \" + SearchService.getLastResults().searchResults.length + \". All results filtered: \" + $scope.allResultsFiltered);\n }\n\n //Returns the content of the property (defined by the current sortPredicate) of the first group element\n $scope.firstResultPredicate = firstResultPredicate;\n\n function firstResultPredicate(item) {\n return item[0][$scope.sortPredicate];\n }\n\n //Returns the unique group identifier which allows angular to keep track of the grouped search results even after filtering, making filtering by indexers a lot faster (albeit still somewhat slow...)\n $scope.groupId = groupId;\n\n function groupId(item) {\n return item[0][0].searchResultId;\n }\n\n $scope.onFilterButtonsModelChange = function () {\n console.log($scope.filterButtonsModel);\n blockAndUpdate();\n };\n\n function blockAndUpdate() {\n startBlocking(\"Sorting / filtering...\").then(function () {\n [$scope.filteredResults, $scope.filterReasons] = sortAndFilter(allSearchResults);\n localStorageService.set(\"sorting\", sortModel);\n });\n }\n\n //Block the UI and return after timeout. This way we make sure that the blocking is done before angular starts updating the model/view. There's probably a better way to achieve that?\n function startBlocking(message) {\n var deferred = $q.defer();\n blockUI.start(message);\n $timeout(function () {\n deferred.resolve();\n }, 10);\n return deferred.promise;\n }\n\n $scope.$on(\"sort\", function (event, column, sortMode, reversed) {\n if (sortMode === 0) {\n sortModel = {\n column: \"epoch\",\n sortMode: 2,\n reversed: true\n };\n } else {\n sortModel = {\n column: column,\n sortMode: sortMode,\n reversed: reversed\n };\n }\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode, sortModel.reversed);\n }, 10);\n blockAndUpdate();\n });\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue && isActive) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n blockAndUpdate();\n });\n\n $scope.resort = function () {\n };\n\n function getCleanedTitle(element) {\n try {\n return element.title.toLowerCase().replace(/[\\s\\-\\._]/ig, \"\");\n } catch (e) {\n console.error(\"Unable to clean title for result \" + element);\n }\n }\n\n function getGroupingString(element) {\n\n var groupingString;\n if ($scope.shared.isGroupEpisodes) {\n groupingString = (element.showtitle + \"x\" + element.season + \"x\" + element.episode).toLowerCase().replace(/[\\._\\-]/ig, \"\");\n if (groupingString === \"nullxnullxnull\") {\n groupingString = getCleanedTitle(element);\n }\n } else {\n groupingString = getCleanedTitle(element);\n if (!$scope.foo.groupTorrentAndNewznabResults) {\n groupingString = groupingString + element.downloadType;\n }\n }\n return groupingString;\n }\n\n function sortAndFilter(results) {\n var query;\n var words;\n var filterReasons = {\n \"tooSmall\": 0,\n \"tooLarge\": 0,\n \"tooYoung\": 0,\n \"tooOld\": 0,\n \"tooFewGrabs\": 0,\n \"tooManyGrabs\": 0,\n \"title\": 0,\n \"tooindexer\": 0,\n \"category\": 0,\n \"tooOld\": 0,\n \"quickFilter\": 0,\n \"alreadyDownloaded\": 0\n\n\n };\n\n if (\"title\" in $scope.filterModel) {\n query = $scope.filterModel.title.filterValue;\n if (!(query.startsWith(\"/\") && query.endsWith(\"/\"))) {\n words = query.toLowerCase().split(/[\\s.\\-]+/);\n }\n }\n\n function filter(item) {\n if (item.title === null || item.title === undefined) {\n //https://github.com/theotherp/nzbhydra2/issues/690\n console.error(\"Item without title: \" + JSON.stringify(item))\n }\n if (\"size\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.size.filterValue;\n if (angular.isDefined(filterValue.min) && item.size / 1024 / 1024 < filterValue.min) {\n filterReasons[\"tooSmall\"] = filterReasons[\"tooSmall\"] + 1;\n return false;\n }\n if (angular.isDefined(filterValue.max) && item.size / 1024 / 1024 > filterValue.max) {\n filterReasons[\"tooLarge\"] = filterReasons[\"tooLarge\"] + 1;\n return false;\n }\n }\n\n if (\"epoch\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.epoch.filterValue;\n\n if (angular.isDefined(filterValue.min)) {\n var min = filterValue.min;\n if (min.endsWith(\"h\")) {\n min = min.replace(\"h\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"hours\");\n } else if (min.endsWith(\"m\")) {\n min = min.replace(\"m\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"minutes\");\n } else {\n var age = moment.utc().diff(moment.unix(item.epoch), \"days\");\n }\n min = Number(min);\n if (age < min) {\n filterReasons[\"tooYoung\"] = filterReasons[\"tooYoung\"] + 1;\n return false;\n }\n }\n\n if (angular.isDefined(filterValue.max)) {\n var max = filterValue.max;\n if (max.endsWith(\"h\")) {\n max = max.replace(\"h\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"hours\");\n } else if (max.endsWith(\"m\")) {\n max = max.replace(\"m\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"minutes\");\n } else {\n var age = moment.utc().diff(moment.unix(item.epoch), \"days\");\n }\n max = Number(max);\n if (age > max) {\n filterReasons[\"tooOld\"] = filterReasons[\"tooOld\"] + 1;\n return false;\n }\n }\n }\n\n\n if (\"grabs\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.grabs.filterValue;\n if (angular.isDefined(filterValue.min)) {\n if ((item.seeders !== null && item.seeders < filterValue.min) || (item.seeders === null && item.grabs !== null && item.grabs < filterValue.min)) {\n filterReasons[\"tooFewGrabs\"] = filterReasons[\"tooFewGrabs\"] + 1;\n return false;\n }\n }\n if (angular.isDefined(filterValue.max)) {\n if ((item.seeders !== null && item.seeders > filterValue.max) || (item.seeders === null && item.grabs !== null && item.grabs > filterValue.max)) {\n filterReasons[\"tooManyGrabs\"] = filterReasons[\"tooManyGrabs\"] + 1;\n return false;\n }\n }\n }\n\n if (\"title\" in $scope.filterModel) {\n var ok;\n if (query.startsWith(\"/\") && query.endsWith(\"/\")) {\n ok = item.title.toLowerCase().match(new RegExp(query.substr(1, query.length - 2), \"gi\"));\n } else {\n ok = _.every(words, function (word) {\n if (word.startsWith(\"!\")) {\n if (word.length === 1) {\n return true;\n }\n return item.title.toLowerCase().indexOf(word.substring(1).toLowerCase()) === -1;\n }\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1;\n });\n }\n\n if (!ok) {\n filterReasons[\"title\"] = filterReasons[\"title\"] + 1;\n return false;\n }\n }\n if (\"indexer\" in $scope.filterModel) {\n if (_.indexOf($scope.filterModel.indexer.filterValue, item.indexer) === -1) {\n filterReasons[\"title\"] = filterReasons[\"title\"] + 1;\n return false;\n }\n }\n if (\"category\" in $scope.filterModel) {\n if (_.indexOf($scope.filterModel.category.filterValue, item.category) === -1) {\n filterReasons[\"category\"] = filterReasons[\"category\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.source !== null) {\n var mustContain = [];\n _.each($scope.filterButtonsModel.source, function (value, key) { //key is something like 'camts', value is true or false\n if (value) {\n Array.prototype.push.apply(mustContain, $scope.filterButtonsModelMap[key]);\n }\n });\n if (mustContain.length > 0) {\n var containsAtLeastOne = _.any(mustContain, function (word) {\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1\n });\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the words \" + JSON.stringify(mustContain));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n }\n if ($scope.filterButtonsModel.quality !== null && !_.isEmpty($scope.filterButtonsModel.quality)) {\n //key is something like 'q720p', value is true or false.\n var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.quality, function (value, key) {\n return value\n }));\n if (requiresAnyOf.length === 0) {\n return true;\n }\n\n var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n if (item.title.toLowerCase().indexOf(required.substring(1).toLowerCase()) > -1) {\n //We need to remove the \"q\" which is there because keys may not start with a digit\n return true;\n }\n })\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the qualities \" + JSON.stringify(requiresAnyOf));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.other !== null && !_.isEmpty($scope.filterButtonsModel.other)) {\n var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.other, function (value, key) {\n return value\n }));\n if (requiresAnyOf.length === 0) {\n return true;\n }\n var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n if (item.title.toLowerCase().indexOf(required.toLowerCase()) > -1) {\n return true;\n }\n })\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the 'other' values \" + JSON.stringify(requiresAnyOf));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.custom !== null && !_.isEmpty($scope.filterButtonsModel.custom)) {\n\n var quickFilterWords = [];\n var quickFilterRegexes = [];\n _.each($scope.filterButtonsModel.custom, function (value, key) { //key is something like 'camts', value is true or false\n if (value) {\n _.each($scope.filterButtonsModelMap[key], function (string) {\n if (string.startsWith(\"/\") && string.endsWith(\"/\")) {\n quickFilterRegexes.push(string);\n } else {\n Array.prototype.push.apply(quickFilterWords, string.split(\" \"));\n }\n });\n }\n });\n if (quickFilterWords.length !== 0) {\n var allMatch = _.all(quickFilterWords, function (word) {\n if (word.startsWith(\"!\")) {\n if (word.length === 1) {\n return true;\n }\n return item.title.toLowerCase().indexOf(word.substring(1).toLowerCase()) === -1;\n }\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1;\n })\n\n if (!allMatch) {\n console.debug(item.title + \" does not match all the terms of \" + JSON.stringify(quickFilterWords));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if (quickFilterRegexes.length !== 0) {\n var allMatch = _.all(quickFilterRegexes, function (regex) {\n return new RegExp(regex.toLowerCase().slice(1, -1)).test(item.title.toLowerCase());\n })\n\n if (!allMatch) {\n console.debug(item.title + \" does not match all the regexes of \" + JSON.stringify(quickFilterRegexes));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n\n\n // var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.custom, function (value, key) {\n // return value\n // }));\n // if (requiresAnyOf.length === 0) {\n // return true;\n // }\n // var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n // if (item.title.toLowerCase().indexOf(required.toLowerCase()) > -1) {\n // return true;\n // }\n // })\n // if (!containsAtLeastOne) {\n // console.debug(item.title + \" does not contain any of the custom values' \" + JSON.stringify(requiresAnyOf));\n // filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n // return false;\n // }\n }\n\n if ($scope.foo.hideAlreadyDownloadedResults && item.downloadedAt !== null) {\n filterReasons[\"alreadyDownloaded\"] = filterReasons[\"alreadyDownloaded\"] + 1;\n return false;\n }\n\n return true;\n }\n\n\n var sortPredicateKey = sortModel.column;\n var sortReversed = sortModel.reversed;\n\n function getSortPredicateValue(containgObject) {\n var sortPredicateValue;\n if (sortPredicateKey === \"grabs\") {\n if (containgObject[\"seeders\"] !== null) {\n sortPredicateValue = containgObject[\"seeders\"];\n } else if (containgObject[\"grabs\"] !== null) {\n sortPredicateValue = containgObject[\"grabs\"];\n } else {\n sortPredicateValue = 0;\n }\n } else if (sortPredicateKey === \"title\") {\n sortPredicateValue = getCleanedTitle(containgObject);\n } else if (sortPredicateKey === \"indexer\") {\n sortPredicateValue = containgObject[\"indexer\"].toLowerCase();\n } else {\n sortPredicateValue = containgObject[sortPredicateKey];\n }\n return sortPredicateValue;\n }\n\n function createSortedHashgroups(titleGroup) {\n function createHashGroup(hashGroup) {\n //Sorting hash group's contents should not matter for size and age and title but might for category (we might remove this, it's probably mostly unnecessary)\n var sortedHashGroup = _.sortBy(hashGroup, function (item) {\n var sortPredicateValue = getSortPredicateValue(item);\n return sortReversed ? -sortPredicateValue : sortPredicateValue;\n });\n //Now sort the hash group by indexer score (inverted) so that the result with the highest indexer score is shown on top (or as the only one of a hash group if it's collapsed)\n sortedHashGroup = _.sortBy(sortedHashGroup, function (item) {\n return item.indexerscore * -1;\n });\n return sortedHashGroup;\n }\n\n function getHashGroupFirstElementSortPredicate(hashGroup) {\n if (sortPredicateKey === \"title\") {\n //Sorting a title group internally by title doesn't make sense so fall back to sorting by age so that newest result is at the top\n return ((10000000000 * hashGroup[0][\"indexerscore\"]) + hashGroup[0][\"epoch\"]) * -1;\n }\n return getSortPredicateValue(hashGroup[0]);\n }\n\n var grouped = _.groupBy(titleGroup, \"hash\");\n var mapped = _.map(grouped, createHashGroup);\n var sorted = _.sortBy(mapped, getHashGroupFirstElementSortPredicate);\n if (sortModel.sortMode === 2 && sortPredicateKey !== \"title\") {\n sorted = sorted.reverse();\n }\n\n return sorted;\n }\n\n function getTitleGroupFirstElementsSortPredicate(titleGroup) {\n var sortPredicateValue;\n if (sortPredicateKey === \"grabs\" && $scope.foo.sumGrabs) {\n var sumOfGrabs = 0;\n _.each(titleGroup, function (element1) {\n _.each(element1, function (element2) {\n sumOfGrabs += getSortPredicateValue(element2);\n })\n });\n\n sortPredicateValue = sumOfGrabs;\n } else {\n sortPredicateValue = getSortPredicateValue(titleGroup[0][0]);\n }\n return sortPredicateValue\n }\n\n _.each(results, function (result) {\n var indexerColor = indexerColors[result.indexer];\n if (indexerColor === undefined || indexerColor === null) {\n return \"\";\n }\n result.style = \"background-color: \" + indexerColor.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\n });\n\n var filtered = _.filter(results, filter);\n $scope.numberOfFilteredResults = results.length - filtered.length;\n $scope.allResultsFiltered = results.length > 0 && ($scope.numberOfFilteredResults === results.length);\n console.log(\"Filtered \" + $scope.numberOfFilteredResults + \" out of \" + results.length);\n var newSelected = $scope.selected;\n _.forEach($scope.selected, function (x) {\n if (x === undefined) {\n return;\n }\n if (filtered.indexOf(x) === -1) {\n $scope.$broadcast(\"toggleSelection\", x, false);\n newSelected.splice($scope.selected.indexOf(x), 1);\n }\n });\n $scope.selected = newSelected;\n\n var grouped = _.groupBy(filtered, getGroupingString);\n\n var mapped = _.map(grouped, createSortedHashgroups);\n var sorted = _.sortBy(mapped, getTitleGroupFirstElementsSortPredicate);\n if (sortModel.sortMode === 2) {\n sorted = sorted.reverse();\n }\n\n var filteredResults = [];\n var countTitleGroups = 0;\n var countResultsUntilTitleGroupLimitReached = 0;\n _.forEach(sorted, function (titleGroup) {\n var titleGroupIndex = 0;\n countTitleGroups++;\n\n _.forEach(titleGroup, function (duplicateGroup) {\n var duplicateIndex = 0;\n _.forEach(duplicateGroup, function (result) {\n try {\n result.titleGroupIndicator = getGroupingString(result);\n result.titleGroupIndex = titleGroupIndex;\n result.duplicateGroupIndex = duplicateIndex;\n result.duplicatesLength = duplicateGroup.length;\n result.titlesLength = titleGroup.length;\n filteredResults.push(result);\n duplicateIndex += 1;\n if (countTitleGroups <= $scope.limitTo) {\n countResultsUntilTitleGroupLimitReached++;\n }\n if (duplicateGroup.length > 1)\n $scope.countDuplicates += (duplicateGroup.length - 1)\n } catch (e) {\n console.error(\"Error while processing result \" + result, e);\n }\n });\n titleGroupIndex += 1;\n });\n });\n $scope.limitTo = Math.max($scope.limitTo, countResultsUntilTitleGroupLimitReached);\n\n $scope.$broadcast(\"calculateDisplayState\");\n\n return [filteredResults, filterReasons];\n }\n\n $scope.toggleTitlegroupExpand = function toggleTitlegroupExpand(titleGroup) {\n $scope.groupExpanded[titleGroup[0][0].title] = !$scope.groupExpanded[titleGroup[0][0].title];\n $scope.groupExpanded[titleGroup[0][0].hash] = !$scope.groupExpanded[titleGroup[0][0].hash];\n };\n\n $scope.stopBlocking = stopBlocking;\n\n function stopBlocking() {\n blockUI.reset();\n }\n\n function setDataFromSearchResult(data, previousSearchResults) {\n allSearchResults = previousSearchResults.concat(data.searchResults);\n allSearchResults = uniq(allSearchResults);\n [$scope.filteredResults, $scope.filterReasons] = sortAndFilter(allSearchResults);\n\n $scope.numberOfAvailableResults = data.numberOfAvailableResults;\n $scope.rejectedReasonsMap = data.rejectedReasonsMap;\n $scope.anyResultsRejected = !_.isEmpty(data.rejectedReasonsMap);\n $scope.anyIndexersSearchedSuccessfully = _.any(data.indexerSearchMetaDatas, function (x) {\n return x.wasSuccessful;\n });\n $scope.numberOfAcceptedResults = data.numberOfAcceptedResults;\n $scope.numberOfRejectedResults = data.numberOfRejectedResults;\n $scope.numberOfProcessedResults = data.numberOfProcessedResults;\n $scope.numberOfDuplicateResults = data.numberOfDuplicateResults;\n $scope.numberOfLoadedResults = allSearchResults.length;\n $scope.indexersearches = data.indexerSearchMetaDatas;\n\n $scope.loadMoreEnabled = ($scope.numberOfLoadedResults + $scope.numberOfRejectedResults < $scope.numberOfAvailableResults) || _.any(data.indexerSearchMetaDatas, function (x) {\n return x.hasMoreResults;\n });\n $scope.totalAvailableUnknown = _.any(data.indexerSearchMetaDatas, function (x) {\n return !x.totalResultsKnown;\n });\n\n if (!$scope.foo.indexerStatusesExpanded && _.any(data.indexerSearchMetaDatas, function (x) {\n return !x.wasSuccessful;\n })) {\n growl.info(\"Errors occurred during searching, Check indexer statuses\")\n }\n //Only show those categories in filter that are actually present in the results\n $scope.categoriesForFiltering = [];\n var allUsedCategories = _.uniq(_.pluck(allSearchResults, \"category\"));\n _.forEach(CategoriesService.getWithoutAll(), function (category) {\n if (allUsedCategories.indexOf(category.name) > -1) {\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\n }\n });\n }\n\n function uniq(searchResults) {\n var seen = {};\n var out = [];\n var len = searchResults.length;\n var j = 0;\n for (var i = 0; i < len; i++) {\n var item = searchResults[i];\n if (seen[item.searchResultId] !== 1) {\n seen[item.searchResultId] = 1;\n out[j++] = item;\n }\n }\n return out;\n }\n\n $scope.loadMore = loadMore;\n\n function loadMore(loadAll) {\n startBlocking(loadAll ? \"Loading all results...\" : \"Loading more results...\").then(function () {\n $scope.loadingMore = true;\n var limit = loadAll ? $scope.numberOfAvailableResults - $scope.numberOfProcessedResults : null;\n SearchService.loadMore($scope.numberOfLoadedResults, limit, loadAll).then(function (data) {\n setDataFromSearchResult(data, allSearchResults);\n $scope.loadingMore = false;\n //stopBlocking();\n });\n });\n }\n\n\n $scope.countResults = countResults;\n\n function countResults() {\n return allSearchResults.length;\n }\n\n $scope.invertSelection = function invertSelection() {\n $scope.$broadcast(\"invertSelection\");\n };\n\n $scope.deselectAll = function deselectAll() {\n $scope.$broadcast(\"deselectAll\");\n };\n\n $scope.selectAll = function selectAll() {\n $scope.$broadcast(\"selectAll\");\n };\n\n $scope.toggleIndexerStatuses = function () {\n $scope.foo.indexerStatusesExpanded = !$scope.foo.indexerStatusesExpanded;\n localStorageService.set(\"indexerStatusesExpanded\", $scope.foo.indexerStatusesExpanded);\n };\n\n $scope.getRejectedReasonsTooltip = function () {\n if (_.isEmpty($scope.rejectedReasonsMap)) {\n return \"No rejected results\";\n } else {\n var tooltip = \"Rejected results:
                  \";\n tooltip += '';\n _.forEach($scope.rejectedReasonsMap, function (count, reason) {\n tooltip += '';\n });\n tooltip += '
                  CountReason
                  ' + count + '' + reason + '
                  ';\n tooltip += '
                  ';\n tooltip += \"Filtered results:
                  \";\n tooltip += '';\n _.forEach($scope.filterReasons, function (count, reason) {\n if (count > 0) {\n tooltip += '';\n }\n });\n tooltip += '
                  CountReason
                  ' + count + '' + reason + '
                  ';\n tooltip += '
                  '\n return tooltip;\n }\n };\n\n\n $scope.$on(\"checkboxClicked\", function (event, originalEvent, newCheckedValue, clickTargetElement) {\n if (originalEvent.shiftKey && $scope.lastClickedElement) {\n $scope.$broadcast(\"shiftClick\", Number($scope.lastClickedValue), $scope.lastClickedElement, clickTargetElement);\n }\n $scope.lastClickedElement = clickTargetElement;\n $scope.lastClickedValue = newCheckedValue;\n });\n\n $scope.$on(\"toggleTitleExpansionUp\", function ($event, value, titleGroupIndicator) {\n $scope.$broadcast(\"toggleTitleExpansionDown\", value, titleGroupIndicator);\n });\n\n $scope.$on(\"toggleDuplicateExpansionUp\", function ($event, value, hash) {\n $scope.$broadcast(\"toggleDuplicateExpansionDown\", value, hash);\n });\n\n $scope.$on(\"selectionUp\", function ($event, result, value) {\n var index = $scope.selected.indexOf(result);\n if (value && index === -1) {\n $scope.selected.push(result);\n } else if (!value && index > -1) {\n $scope.selected.splice(index, 1);\n }\n });\n\n $scope.downloadNzbsCallback = function (addedIds) {\n if (addedIds !== null && addedIds.length > 0) {\n growl.info(\"Removing downloaded NZBs from selection\");\n var toRemove = _.filter($scope.selected, function (x) {\n return addedIds.indexOf(Number(x.searchResultId)) > -1;\n });\n var newSelected = $scope.selected;\n _.forEach(toRemove, function (x) {\n $scope.$broadcast(\"toggleSelection\", x, false);\n newSelected.splice($scope.selected.indexOf(x), 1);\n });\n $scope.selected = newSelected;\n }\n };\n\n\n $scope.filterRejectedZero = function () {\n return function (entry) {\n return entry[1] > 0;\n }\n };\n\n $scope.onPageChange = function (newPageNumber, oldPageNumber) {\n _.each($scope.selected, function (x) {\n $scope.$broadcast(\"toggleSelection\", x, true);\n })\n };\n\n $scope.$on(\"onFinishRender\", function () {\n console.log(\"Finished rendering results.\")\n $scope.doShowResults = true;\n $timeout(function () {\n if ($scope.foo.scrollToResults) {\n var searchResultsElement = angular.element(document.getElementById('display-options'));\n $document.scrollToElement(searchResultsElement, 0, 500);\n }\n stopBlocking();\n console.log(\"Closing search status window because rendering is finished.\")\n SearchService.getModalInstance().close();\n }, 1);\n });\n\n\n $timeout(function () {\n DebugService.print();\n }, 3000);\n\n // $timeout(function () {\n // function getWatchers(root) {\n // root = angular.element(root || document.documentElement);\n // var watcherCount = 0;\n // var ids = [];\n //\n // function getElemWatchers(element, ids) {\n // var isolateWatchers = getWatchersFromScope(element.data().$isolateScope, ids);\n // var scopeWatchers = getWatchersFromScope(element.data().$scope, ids);\n // var watchers = scopeWatchers.concat(isolateWatchers);\n // angular.forEach(element.children(), function (childElement) {\n // watchers = watchers.concat(getElemWatchers(angular.element(childElement), ids));\n // });\n // return watchers;\n // }\n //\n // function getWatchersFromScope(scope, ids) {\n // if (scope) {\n // if (_.indexOf(ids, scope.$id) > -1) {\n // return [];\n // }\n // ids.push(scope.$id);\n // if (scope.$$watchers) {\n // if (scope.$$watchers.length > 1) {\n // var a;\n // a = 1;\n // }\n // return scope.$$watchers;\n // }\n // {\n // return [];\n // }\n //\n // } else {\n // return [];\n // }\n // }\n //\n // return getElemWatchers(root, ids);\n // }\n //\n // }, $scope.limitTo);\n}\n","\r\nSearchHistoryService.$inject = [\"$filter\", \"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('SearchHistoryService', SearchHistoryService);\r\n\r\nfunction SearchHistoryService($filter, $http) {\r\n\r\n return {\r\n getSearchHistory: getSearchHistory,\r\n getSearchHistoryForSearching: getSearchHistoryForSearching,\r\n formatRequest: formatRequest,\r\n getStateParamsForRepeatedSearch: getStateParamsForRepeatedSearch\r\n };\r\n\r\n function getSearchHistoryForSearching() {\r\n return $http.post(\"internalapi/history/searches/forsearching\").then(function (response) {\r\n return {\r\n searchRequests: response.data\r\n }\r\n });\r\n }\r\n\r\n function getSearchHistory(pageNumber, limit, filterModel, sortModel, distinct, onlyCurrentUser) {\r\n var params = {\r\n page: pageNumber,\r\n limit: limit,\r\n filterModel: filterModel,\r\n distinct: distinct,\r\n onlyCurrentUser: onlyCurrentUser\r\n };\r\n if (angular.isUndefined(pageNumber)) {\r\n params.page = 1;\r\n }\r\n if (angular.isUndefined(limit)) {\r\n params.limit = 100;\r\n }\r\n if (angular.isUndefined(filterModel)) {\r\n params.filterModel = {}\r\n }\r\n if (!angular.isUndefined(sortModel)) {\r\n params.sortModel = sortModel;\r\n } else {\r\n params.sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n }\r\n return $http.post(\"internalapi/history/searches\", params).then(function (response) {\r\n return {\r\n searchRequests: response.data.content,\r\n totalRequests: response.data.totalElements\r\n }\r\n });\r\n }\r\n\r\n function formatRequest(request, includeIdLink, includequery, describeEmptySearch, includeTitle) {\r\n var result = [];\r\n result.push('Category: ' + request.categoryName);\r\n if (includequery && request.query) {\r\n result.push('Query: ' + request.query);\r\n }\r\n if (request.title && includeTitle) {\r\n result.push('Title: ' + request.title);\r\n } //Only include identifiers if title is unknown\r\n else if (request.identifiers.length > 0) {\r\n var href;\r\n var key;\r\n var value;\r\n var identifiers = _.indexBy(request.identifiers, 'identifierKey');\r\n if (\"IMDB\" in identifiers) {\r\n key = \"IMDB ID\";\r\n value = identifiers.IMDB.identifierValue;\r\n href = \"https://www.imdb.com/title/tt\" + value;\r\n } else if (\"TVDB\" in identifiers) {\r\n key = \"TVDB ID\";\r\n value = identifiers.TVDB.identifierValue;\r\n href = \"https://thetvdb.com/?tab=series&id=\" + value;\r\n } else if (\"TVRAGE\" in identifiers) {\r\n key = \"TVRage ID\";\r\n value = identifiers.TVRAGE.identifierValue;\r\n href = \"internalapi/redirect_rid?rid=\" + value;\r\n } else if (\"TMDB\" in identifiers) {\r\n key = \"TMDB ID\";\r\n value = identifiers.TMDB.identifierValue;\r\n href = \"https://www.themoviedb.org/movie/\" + value;\r\n }\r\n href = $filter(\"dereferer\")(href);\r\n if (includeIdLink) {\r\n result.push('' + key + ': ' + value + \"\");\r\n } else {\r\n result.push('' + key + \": \" + value);\r\n }\r\n }\r\n if (request.season) {\r\n result.push('Season: ' + request.season);\r\n }\r\n if (request.episode) {\r\n result.push('Episode: ' + request.episode);\r\n }\r\n if (request.author) {\r\n result.push('Author: ' + request.author);\r\n }\r\n if (result.length === 0 && describeEmptySearch) {\r\n result = ['Empty search'];\r\n }\r\n\r\n return result.join(\", \");\r\n\r\n }\r\n\r\n function getStateParamsForRepeatedSearch(request) {\r\n var stateParams = {};\r\n stateParams.mode = \"search\";\r\n var availableIdentifiers = _.pluck(request.identifiers, \"identifierKey\");\r\n if (availableIdentifiers.indexOf(\"TMDB\") > -1 || availableIdentifiers.indexOf(\"IMDB\") > -1) {\r\n stateParams.mode = \"movie\";\r\n } else if (availableIdentifiers.indexOf(\"TVRAGE\") > -1 || availableIdentifiers.indexOf(\"TVMAZE\") > -1 || availableIdentifiers.indexOf(\"TVDB\") > -1) {\r\n stateParams.mode = \"tvsearch\";\r\n }\r\n if (request.season) {\r\n stateParams.season = request.season;\r\n }\r\n if (request.episode) {\r\n stateParams.episode = request.episode;\r\n }\r\n\r\n _.each(request.identifiers, function (entry) {\r\n switch (entry.identifierKey) {\r\n case \"TMDB\":\r\n stateParams.tmdbId = entry.identifierValue;\r\n break;\r\n case \"IMDB\":\r\n stateParams.imdbId = entry.identifierValue;\r\n break;\r\n case \"TVMAZE\":\r\n stateParams.tvmazeId = entry.identifierValue;\r\n break;\r\n case \"TVRAGE\":\r\n stateParams.tvrageId = entry.identifierValue;\r\n break;\r\n case \"TVDB\":\r\n stateParams.tvdbId = entry.identifierValue;\r\n break;\r\n }\r\n });\r\n\r\n\r\n if (request.query !== \"\") {\r\n stateParams.query = request.query;\r\n }\r\n\r\n if (request.title) {\r\n stateParams.title = request.title;\r\n }\r\n\r\n if (request.categoryName) {\r\n stateParams.category = request.categoryName;\r\n }\r\n\r\n return stateParams;\r\n }\r\n\r\n\r\n}","\r\nSearchHistoryController.$inject = [\"$scope\", \"$state\", \"SearchHistoryService\", \"ConfigService\", \"localStorageService\", \"history\", \"$sce\", \"$filter\", \"$timeout\", \"$http\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .controller('SearchHistoryController', SearchHistoryController);\r\n\r\n\r\nfunction SearchHistoryController($scope, $state, SearchHistoryService, ConfigService, localStorageService, history, $sce, $filter, $timeout, $http, $uibModal) {\r\n $scope.limit = 100;\r\n $scope.pagination = {\r\n current: 1\r\n };\r\n var sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n $timeout(function () {\r\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\r\n }, 10);\r\n $scope.filterModel = {};\r\n\r\n //Filter options\r\n $scope.categoriesForFiltering = [];\r\n _.forEach(ConfigService.getSafe().categoriesConfig.categories, function (category) {\r\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\r\n });\r\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\r\n $scope.accessOptionsForFiltering = [{label: \"All\", value: \"all\"}, {label: \"API\", value: 'API'}, {\r\n label: \"Internal\",\r\n value: 'INTERNAL'\r\n }];\r\n\r\n //Preloaded data\r\n $scope.searchRequests = history.searchRequests;\r\n $scope.totalRequests = history.totalRequests;\r\n\r\n var anyUsername = false;\r\n var anyIp = false;\r\n for (var request of $scope.searchRequests) {\r\n if (request.username) {\r\n anyUsername = true;\r\n }\r\n if (request.ip) {\r\n anyIp = true;\r\n }\r\n if (anyIp && anyUsername) {\r\n break;\r\n }\r\n }\r\n\r\n $scope.foo = {\r\n showUserAgentInHistory: localStorageService.get(\"showUserAgentInHistory\") !== null ? localStorageService.get(\"showUserAgentInHistory\") : false\r\n };\r\n\r\n\r\n $scope.toggleShowUserAgentInHistory = function (value) {\r\n let doUpdateColumnSizes = value !== localStorageService.get(\"showUserAgentInHistory\");\r\n localStorageService.set(\"showUserAgentInHistory\", value);\r\n $scope.foo.showUserAgentInHistory = value;\r\n if (doUpdateColumnSizes) {\r\n setColumnSizes();\r\n }\r\n }\r\n\r\n function setColumnSizes() {\r\n $scope.columnSizes = {\r\n time: 10,\r\n query: 30,\r\n userAgent: 0,\r\n category: 10,\r\n additionalParameters: 22,\r\n source: 8,\r\n username: 10,\r\n ip: 10\r\n };\r\n if (ConfigService.getSafe().logging.historyUserInfoType === \"NONE\" || (!anyUsername && !anyIp)) {\r\n $scope.columnSizes.username = 0;\r\n $scope.columnSizes.ip = 0;\r\n $scope.columnSizes.query += 10;\r\n $scope.columnSizes.additionalParameters += 10;\r\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"IP\") {\r\n $scope.columnSizes.username = 0;\r\n $scope.columnSizes.query += 5;\r\n $scope.columnSizes.additionalParameters += 5;\r\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"USERNAME\") {\r\n $scope.columnSizes.ip = 0;\r\n $scope.columnSizes.query += 5;\r\n $scope.columnSizes.additionalParameters += 5;\r\n }\r\n if ($scope.foo.showUserAgentInHistory) {\r\n $scope.columnSizes.query -= 5;\r\n $scope.columnSizes.additionalParameters -= 5;\r\n $scope.columnSizes.userAgent = 10;\r\n }\r\n }\r\n\r\n setColumnSizes();\r\n\r\n\r\n $scope.update = function () {\r\n SearchHistoryService.getSearchHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (history) {\r\n $scope.searchRequests = history.searchRequests;\r\n $scope.totalRequests = history.totalRequests;\r\n });\r\n };\r\n\r\n $scope.$on(\"sort\", function (event, column, sortMode) {\r\n if (sortMode === 0) {\r\n sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n } else {\r\n sortModel = {\r\n column: column,\r\n sortMode: sortMode\r\n };\r\n }\r\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\r\n $scope.update();\r\n });\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\r\n if (filterModel.filterValue) {\r\n $scope.filterModel[column] = filterModel;\r\n } else {\r\n delete $scope.filterModel[column];\r\n }\r\n $scope.update();\r\n });\r\n\r\n\r\n $scope.openSearch = function (request) {\r\n $state.go(\"root.search\", SearchHistoryService.getStateParamsForRepeatedSearch(request), {\r\n inherit: false,\r\n notify: true,\r\n reload: true\r\n });\r\n };\r\n\r\n $scope.formatQuery = function (request) {\r\n if (request.title) {\r\n return request.title;\r\n }\r\n\r\n if (!request.query && request.identifiers.length === 0 && !request.season && !request.episode) {\r\n return \"Update query\";\r\n }\r\n return request.query;\r\n };\r\n\r\n $scope.formatAdditional = function (request) {\r\n var result = [];\r\n if (request.identifiers.length > 0) {\r\n var href;\r\n var key;\r\n var value;\r\n var pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TMDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TMDB ID\";\r\n href = \"https://www.themoviedb.org/movie/\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"IMDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"IMDB ID\";\r\n href = (\"https://www.imdb.com/title/tt\" + pair.identifierValue).replace(\"tttt\", \"tt\");\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVDB ID\";\r\n href = \"https://thetvdb.com/?tab=series&id=\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVMAZE\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVMAZE ID\";\r\n href = \"https://www.tvmaze.com/shows/\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVRAGE\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVRage ID\";\r\n href = \"internalapi/redirectRid/\" + pair.identifierValue;\r\n value = pair.identifierValue;\r\n }\r\n\r\n result.push(key + \": \" + '' + value + \"\");\r\n }\r\n if (request.season) {\r\n result.push(\"Season: \" + request.season);\r\n }\r\n if (request.episode) {\r\n result.push(\"Episode: \" + request.episode);\r\n }\r\n if (request.author) {\r\n result.push(\"Author: \" + request.author);\r\n }\r\n return $sce.trustAsHtml(result.join(\", \"));\r\n };\r\n\r\n $scope.showDetails = function (searchId) {\r\n\r\n ModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"searchId\"];\r\n function ModalInstanceCtrl($scope, $uibModalInstance, $http, searchId) {\r\n $http.get(\"internalapi/history/searches/details/\" + searchId).then(function (response) {\r\n $scope.details = response.data;\r\n });\r\n }\r\n\r\n $uibModal.open({\r\n templateUrl: 'static/html/search-history-details-modal.html',\r\n controller: ModalInstanceCtrl,\r\n size: \"md\",\r\n resolve: {\r\n searchId: function () {\r\n return searchId;\r\n }\r\n }\r\n });\r\n\r\n\r\n }\r\n\r\n}\r\n\r\n","\r\nSearchController.$inject = [\"$scope\", \"$http\", \"$stateParams\", \"$state\", \"$uibModal\", \"$timeout\", \"$sce\", \"growl\", \"SearchService\", \"focus\", \"ConfigService\", \"HydraAuthService\", \"CategoriesService\", \"$element\", \"SearchHistoryService\"];\r\nSearchUpdateModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"SearchService\", \"$uibModalInstance\", \"searchRequestId\", \"onCancel\", \"bootstrapped\"];angular\r\n .module('nzbhydraApp')\r\n .controller('SearchController', SearchController);\r\n\r\nfunction SearchController($scope, $http, $stateParams, $state, $uibModal, $timeout, $sce, growl, SearchService, focus, ConfigService, HydraAuthService, CategoriesService, $element, SearchHistoryService) {\r\n\r\n function getNumberOrUndefined(number) {\r\n if (_.isUndefined(number) || _.isNaN(number) || number === \"\") {\r\n return undefined;\r\n }\r\n number = parseInt(number);\r\n if (_.isNumber(number)) {\r\n return number;\r\n } else {\r\n return undefined;\r\n }\r\n }\r\n\r\n var searchRequestId = 0;\r\n var isSearchCancelled = false;\r\n var epochEnter;\r\n\r\n //Fill the form with the search values we got from the state params (so that their values are the same as in the current url)\r\n $scope.mode = $stateParams.mode;\r\n $scope.query = \"\";\r\n $scope.selectedItem = null;\r\n $scope.categories = _.filter(CategoriesService.getAllCategories(), function (c) {\r\n return c.mayBeSelected && !(c.ignoreResultsFrom === \"INTERNAL\" || c.ignoreResultsFrom === \"BOTH\");\r\n });\r\n $scope.minsize = getNumberOrUndefined($stateParams.minsize);\r\n $scope.maxsize = getNumberOrUndefined($stateParams.maxsize);\r\n if (angular.isDefined($stateParams.category) && $stateParams.category) {\r\n $scope.category = CategoriesService.getByName($stateParams.category);\r\n } else {\r\n $scope.category = CategoriesService.getDefault();\r\n $scope.minsize = $scope.category.minSizePreset;\r\n $scope.maxsize = $scope.category.maxSizePreset;\r\n }\r\n $scope.category = _.isNullOrEmpty($stateParams.category) ? CategoriesService.getDefault() : CategoriesService.getByName($stateParams.category);\r\n $scope.season = $stateParams.season;\r\n $scope.episode = $stateParams.episode;\r\n $scope.query = $stateParams.query;\r\n\r\n $scope.minage = getNumberOrUndefined($stateParams.minage);\r\n $scope.maxage = getNumberOrUndefined($stateParams.maxage);\r\n if (angular.isDefined($stateParams.indexers)) {\r\n $scope.indexers = decodeURIComponent($stateParams.indexers).split(\",\");\r\n }\r\n if (angular.isDefined($stateParams.title) || (angular.isDefined($stateParams.tmdbId) || angular.isDefined($stateParams.imdbId) || angular.isDefined($stateParams.tvmazeId) || angular.isDefined($stateParams.rid) || angular.isDefined($stateParams.tvdbId))) {\r\n var width = calculateWidth($stateParams.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n $scope.selectedItem = {\r\n tmdbId: $stateParams.tmdbId,\r\n imdbId: $stateParams.imdbId,\r\n tvmazeId: $stateParams.tvmazeId,\r\n rid: $stateParams.rid,\r\n tvdbId: $stateParams.tvdbId,\r\n title: $stateParams.title\r\n }\r\n }\r\n\r\n $scope.showIndexers = {};\r\n\r\n $scope.searchHistory = [];\r\n\r\n var safeConfig = ConfigService.getSafe();\r\n $scope.showIndexerSelection = HydraAuthService.getUserInfos().showIndexerSelection;\r\n\r\n\r\n $scope.typeAheadWait = 300;\r\n\r\n $scope.autocompleteLoading = false;\r\n $scope.isAskById = $scope.category.searchType === \"TVSEARCH\" || $scope.category.searchType === \"MOVIE\";\r\n $scope.isById = {value: $scope.selectedItem !== null || angular.isUndefined($scope.mode) || $scope.mode === null}; //If true the user wants to search by id so we enable autosearch. Was unable to achieve this using a simple boolean. Set to false if last search was not by ID\r\n $scope.availableIndexers = [];\r\n $scope.selectedIndexers = [];\r\n $scope.autocompleteClass = \"autocompletePosterMovies\";\r\n\r\n $scope.toggleCategory = function (searchCategory) {\r\n var oldCategory = $scope.category;\r\n $scope.category = searchCategory;\r\n\r\n //Show checkbox to ask if the user wants to search by ID (using autocomplete)\r\n if ($scope.category.searchType === \"TVSEARCH\" || $scope.category.searchType === \"MOVIE\") {\r\n $scope.isAskById = true;\r\n $scope.isById.value = true;\r\n } else {\r\n $scope.isAskById = false;\r\n $scope.isById.value = false;\r\n }\r\n\r\n if (oldCategory.searchType !== searchCategory.searchType) {\r\n $scope.selectedItem = null;\r\n }\r\n\r\n focus('searchfield');\r\n\r\n //Hacky way of triggering the autocomplete loading\r\n var searchModel = $element.find(\"#searchfield\").controller(\"ngModel\");\r\n if (angular.isDefined(searchModel.$viewValue)) {\r\n searchModel.$setViewValue(searchModel.$viewValue + \" \");\r\n }\r\n\r\n if (safeConfig.categoriesConfig.enableCategorySizes) {\r\n var min = searchCategory.minSizePreset;\r\n var max = searchCategory.maxSizePreset;\r\n if (_.isNumber(min)) {\r\n $scope.minsize = min;\r\n } else {\r\n $scope.minsize = \"\";\r\n }\r\n if (_.isNumber(max)) {\r\n $scope.maxsize = max;\r\n } else {\r\n $scope.maxsize = \"\";\r\n }\r\n }\r\n\r\n $scope.availableIndexers = getAvailableIndexers();\r\n };\r\n\r\n // Any function returning a promise object can be used to load values asynchronously\r\n $scope.getAutocomplete = function (val) {\r\n $scope.autocompleteLoading = true;\r\n //Expected model returned from API:\r\n //label: What to show in the results\r\n //title: Will be used for file search\r\n //value: Will be used as extraInfo (ttid oder tvdb id)\r\n //poster: url of poster to show\r\n\r\n //Don't use autocomplete if checkbox is disabled\r\n if (!$scope.isById.value || $scope.selectedItem) {\r\n return {};\r\n }\r\n\r\n if ($scope.category.searchType === \"MOVIE\") {\r\n return $http.get('internalapi/autocomplete/MOVIE', {params: {input: val}}).then(function (response) {\r\n $scope.autocompleteLoading = false;\r\n return response.data;\r\n });\r\n } else if ($scope.category.searchType === \"TVSEARCH\") {\r\n return $http.get('internalapi/autocomplete/TV', {params: {input: val}}).then(function (response) {\r\n $scope.autocompleteLoading = false;\r\n return response.data;\r\n });\r\n } else {\r\n return {};\r\n }\r\n };\r\n\r\n $scope.onTypeAheadEnter = function () {\r\n if (angular.isDefined(epochEnter)) {\r\n //Very hacky way of preventing a press of \"enter\" to select an autocomplete item from triggering a search\r\n //This is called *after* selectAutoComplete() is called\r\n var epochEnterNow = (new Date).getTime();\r\n var diff = epochEnterNow - epochEnter;\r\n if (diff > 50) {\r\n $scope.initiateSearch();\r\n }\r\n } else {\r\n $scope.initiateSearch();\r\n }\r\n };\r\n\r\n $scope.onTypeAheadKeyDown = function (event) {\r\n if (event.keyCode === 8) {\r\n if ($scope.query === \"\") {\r\n $scope.clearAutocomplete();\r\n }\r\n }\r\n };\r\n\r\n $scope.onDropOnQueryInput = function (event) {\r\n if ($scope.searchHistoryDragged === null || $scope.searchHistoryDragged === undefined) {\r\n return;\r\n }\r\n\r\n $scope.category = CategoriesService.getByName($scope.searchHistoryDragged.categoryName);\r\n $scope.season = $scope.searchHistoryDragged.season;\r\n $scope.episode = $scope.searchHistoryDragged.episode;\r\n $scope.query = $scope.searchHistoryDragged.query;\r\n\r\n if ($scope.searchHistoryDragged.title != null) {\r\n var width = calculateWidth($scope.searchHistoryDragged.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n }\r\n\r\n var tvmaze = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TVMAZE\"});\r\n var tmdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TMDB\"});\r\n var imdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"IMDB\"});\r\n var tvdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TVDB\"});\r\n $scope.selectedItem = {\r\n tmdbId: tmdb === undefined ? null : tmdb.identifierValue,\r\n imdbId: imdb === undefined ? null : imdb.identifierValue,\r\n tvmazeId: tvmaze === undefined ? null : tvmaze.identifierValue,\r\n tvdbId: tvdb === undefined ? null : tvdb.identifierValue,\r\n title: $scope.searchHistoryDragged.title\r\n }\r\n\r\n event.preventDefault();\r\n\r\n $scope.searchHistoryDragged = null;\r\n focus('searchfield');\r\n $scope.status.isopen = false;\r\n }\r\n\r\n $scope.$on(\"searchHistoryDrag\", function (event, data) {\r\n $scope.searchHistoryDragged = JSON.parse(data);\r\n })\r\n\r\n //Is called when the search page is opened with params, either because the user initiated the search (which triggered a goTo to this page) or because a search URL was entered\r\n $scope.startSearch = function () {\r\n isSearchCancelled = false;\r\n searchRequestId = Math.round(Math.random() * 99999);\r\n var modalInstance = $scope.openModal(searchRequestId);\r\n\r\n var indexers = angular.isUndefined($scope.indexers) ? undefined : $scope.indexers.join(\",\");\r\n SearchService.search(searchRequestId, $scope.category.name, $scope.query, $scope.selectedItem, $scope.season, $scope.episode, $scope.minsize, $scope.maxsize, $scope.minage, $scope.maxage, indexers, $scope.mode).then(function () {\r\n //modalInstance.close();\r\n SearchService.setModalInstance(modalInstance);\r\n if (!isSearchCancelled) {\r\n $state.go(\"root.search.results\", {\r\n minsize: $scope.minsize,\r\n maxsize: $scope.maxsize,\r\n minage: $scope.minage,\r\n maxage: $scope.maxage\r\n }, {\r\n inherit: true\r\n });\r\n }\r\n },\r\n function () {\r\n modalInstance.close();\r\n });\r\n };\r\n\r\n $scope.openModal = function openModal(searchRequestId) {\r\n return $uibModal.open({\r\n templateUrl: 'static/html/search-state.html',\r\n controller: SearchUpdateModalInstanceCtrl,\r\n size: \"md\",\r\n backdrop: \"static\",\r\n backdropClass: \"waiting-cursor\",\r\n resolve: {\r\n searchRequestId: function () {\r\n return searchRequestId;\r\n },\r\n onCancel: function () {\r\n function cancel() {\r\n isSearchCancelled = true;\r\n }\r\n\r\n return cancel;\r\n }\r\n }\r\n });\r\n };\r\n\r\n $scope.goToSearchUrl = function () {\r\n //State params (query parameters) should all be lowercase\r\n var stateParams = {};\r\n stateParams.mode = $scope.category.searchType.toLowerCase();\r\n stateParams.imdbId = $scope.selectedItem === null ? null : $scope.selectedItem.imdbId;\r\n stateParams.tmdbId = $scope.selectedItem === null ? null : $scope.selectedItem.tmdbId;\r\n stateParams.tvdbId = $scope.selectedItem === null ? null : $scope.selectedItem.tvdbId;\r\n stateParams.tvrageId = $scope.selectedItem === null ? null : $scope.selectedItem.tvrageId;\r\n stateParams.tvmazeId = $scope.selectedItem === null ? null : $scope.selectedItem.tvmazeId;\r\n stateParams.title = $scope.selectedItem === null ? null : $scope.selectedItem.title;\r\n stateParams.season = $scope.season;\r\n stateParams.episode = $scope.episode;\r\n stateParams.query = $scope.query;\r\n stateParams.minsize = $scope.minsize;\r\n stateParams.maxsize = $scope.maxsize;\r\n stateParams.minage = $scope.minage;\r\n stateParams.maxage = $scope.maxage;\r\n stateParams.category = $scope.category.name;\r\n stateParams.indexers = encodeURIComponent($scope.selectedIndexers.join(\",\"));\r\n $state.go(\"root.search\", stateParams, {inherit: false, notify: true, reload: true});\r\n };\r\n\r\n $scope.repeatSearch = function (request) {\r\n var stateParams = SearchHistoryService.getStateParamsForRepeatedSearch(request);\r\n stateParams.indexers = encodeURIComponent($scope.selectedIndexers.join(\",\"));\r\n $state.go(\"root.search\", stateParams, {inherit: false, notify: true, reload: true});\r\n };\r\n\r\n $scope.searchBoxTooltip = \"Prefix terms with -- to exclude'\";\r\n $scope.$watchGroup(['isAskById', 'selectedItem'], function () {\r\n if (!$scope.isAskById) {\r\n $scope.searchBoxTooltip = \"Prefix terms with -- to exclude\";\r\n } else if ($scope.selectedItem === null) {\r\n $scope.searchBoxTooltip = \"Enter search terms for autocomplete\";\r\n } else {\r\n $scope.searchBoxTooltip = \"Enter additional search terms to limit the query\";\r\n }\r\n });\r\n\r\n $scope.clearAutocomplete = function () {\r\n $scope.selectedItem = null;\r\n $scope.query = \"\"; //Input is now for autocomplete and not for limiting the results\r\n focus('searchfield');\r\n };\r\n\r\n $scope.clearQuery = function () {\r\n $scope.selectedItem = null;\r\n $scope.query = \"\";\r\n focus('searchfield');\r\n };\r\n\r\n function calculateWidth(text) {\r\n var canvas = calculateWidth.canvas || (calculateWidth.canvas = document.createElement(\"canvas\"));\r\n var context = canvas.getContext(\"2d\");\r\n context.font = \"13px Roboto\";\r\n return context.measureText(text).width;\r\n }\r\n\r\n $scope.selectAutocompleteItem = function ($item) {\r\n $scope.selectedItem = $item;\r\n $scope.query = \"\";\r\n epochEnter = (new Date).getTime();\r\n var width = calculateWidth($item.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n };\r\n\r\n $scope.initiateSearch = function () {\r\n if ($scope.selectedIndexers.length === 0) {\r\n growl.error(\"You didn't select any indexers\");\r\n return;\r\n }\r\n if ($scope.selectedItem) {\r\n //Movie or tv show was selected\r\n $scope.goToSearchUrl();\r\n } else {\r\n //Simple query search\r\n $scope.goToSearchUrl();\r\n }\r\n };\r\n\r\n $scope.autocompleteActive = function () {\r\n return $scope.isAskById;\r\n };\r\n\r\n $scope.seriesSelected = function () {\r\n return $scope.category.searchType === \"TVSEARCH\";\r\n };\r\n\r\n $scope.toggleIndexer = function (indexer) {\r\n $scope.availableIndexers[indexer.name].activated = !$scope.availableIndexers[indexer.name].activated;\r\n };\r\n\r\n function isIndexerPreselected(indexer) {\r\n if (angular.isUndefined($scope.indexers)) {\r\n return indexer.preselect;\r\n } else {\r\n return _.contains($scope.indexers, indexer.name);\r\n }\r\n }\r\n\r\n function getAvailableIndexers() {\r\n var alreadySelected = $scope.selectedIndexers;\r\n var previouslyAvailable = _.pluck($scope.availableIndexers, \"name\");\r\n $scope.selectedIndexers = [];\r\n var availableIndexersList = _.chain(safeConfig.indexers).filter(function (indexer) {\r\n if (!indexer.showOnSearch) {\r\n return false;\r\n }\r\n var categorySelectedForIndexer = (angular.isUndefined(indexer.categories) || indexer.categories.length === 0 || $scope.category.name.toLowerCase() === \"all\" || indexer.categories.indexOf($scope.category.name) > -1);\r\n return categorySelectedForIndexer;\r\n }).sortBy(function (indexer) {\r\n return indexer.name.toLowerCase();\r\n })\r\n .map(function (indexer) {\r\n return {\r\n name: indexer.name,\r\n activated: isIndexerPreselected(indexer),\r\n preselect: indexer.preselect,\r\n categories: indexer.categories,\r\n searchModuleType: indexer.searchModuleType\r\n };\r\n }).value();\r\n _.forEach(availableIndexersList, function (x) {\r\n var deselectedBefore = (_.indexOf(previouslyAvailable, x.name) > -1 && _.indexOf(alreadySelected, x.name) === -1);\r\n var selectedBefore = (_.indexOf(previouslyAvailable, x.name) > -1 && _.indexOf(alreadySelected, x.name) > -1);\r\n if ((x.activated && !deselectedBefore) || selectedBefore) {\r\n $scope.selectedIndexers.push(x.name);\r\n }\r\n });\r\n return availableIndexersList;\r\n }\r\n\r\n\r\n $scope.formatRequest = function (request) {\r\n return $sce.trustAsHtml(SearchHistoryService.formatRequest(request, false, true, true, true));\r\n };\r\n\r\n $scope.availableIndexers = getAvailableIndexers();\r\n\r\n function getAndSetSearchRequests() {\r\n SearchHistoryService.getSearchHistoryForSearching().then(function (response) {\r\n $scope.searchHistory = response.searchRequests;\r\n });\r\n }\r\n\r\n if ($scope.mode) {\r\n $scope.startSearch();\r\n } else {\r\n //Getting the search history only makes sense when we're not currently searching\r\n _.defer(getAndSetSearchRequests);\r\n }\r\n\r\n $scope.$on(\"searchResultsShown\", function () {\r\n _.defer(getAndSetSearchRequests); //Defer because otherwise the results are only shown when this returns which may take a while with big databases\r\n });\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('SearchUpdateModalInstanceCtrl', SearchUpdateModalInstanceCtrl);\r\n\r\nfunction SearchUpdateModalInstanceCtrl($scope, $interval, SearchService, $uibModalInstance, searchRequestId, onCancel, bootstrapped) {\r\n\r\n var loggedSearchFinished = false;\r\n $scope.messages = [];\r\n $scope.indexerSelectionFinished = false;\r\n $scope.indexersSelected = 0;\r\n $scope.indexersFinished = 0;\r\n $scope.buttonText = \"Cancel\";\r\n $scope.buttonTooltip = \"Cancel search and return to search mask\";\r\n $scope.btnType = \"btn-danger\";\r\n\r\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\r\n var stompClient = Stomp.over(socket);\r\n stompClient.debug = null;\r\n stompClient.connect({}, function (frame) {\r\n stompClient.subscribe('/topic/searchState', function (message) {\r\n var data = JSON.parse(message.body);\r\n if (searchRequestId !== data.searchRequestId) {\r\n return;\r\n }\r\n $scope.searchFinished = data.searchFinished;\r\n $scope.indexersSelected = data.indexersSelected;\r\n $scope.indexersFinished = data.indexersFinished;\r\n $scope.progressMax = data.indexersSelected;\r\n if ($scope.progressMax > data.indexersSelected) {\r\n $scope.progressMax = \">=\" + data.indexersSelected;\r\n }\r\n if ($scope.indexersFinished > 0) {\r\n $scope.buttonText = \"Show results\";\r\n $scope.buttonTooltip = \"Show results that have already been loaded\";\r\n $scope.btnType = \"btn-warning\";\r\n }\r\n if (data.messages) {\r\n $scope.messages = data.messages;\r\n }\r\n if ($scope.searchFinished && !loggedSearchFinished) {\r\n $scope.messages.push(\"Finished searching. Preparing results...\");\r\n loggedSearchFinished = true;\r\n }\r\n });\r\n });\r\n\r\n $scope.shortcutSearch = function () {\r\n SearchService.shortcutSearch(searchRequestId);\r\n // onCancel();\r\n // $uibModalInstance.dismiss();\r\n };\r\n\r\n $scope.hasResults = function (message) {\r\n return /^[^0]\\d+.*/.test(message);\r\n };\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive('draggable', ['$rootScope', function ($rootScope) {\r\n return {\r\n restrict: 'A',\r\n link: function (scope, el, attrs, controller) {\r\n\r\n el.bind(\"dragstart\", function (e) {\r\n $rootScope.$emit(\"searchHistoryDrag\", el.attr(\"data-request\"));\r\n $rootScope.$broadcast(\"searchHistoryDrag\", el.attr(\"data-request\"));\r\n });\r\n }\r\n }\r\n}]);\r\n\r\n","\r\nRestartService.$inject = [\"growl\", \"NzbHydraControlService\", \"$uibModal\"];\r\nRestartModalInstanceCtrl.$inject = [\"$scope\", \"$timeout\", \"$http\", \"$window\", \"RequestsErrorHandler\", \"message\", \"baseUrl\"];angular\r\n .module('nzbhydraApp')\r\n .factory('RestartService', RestartService);\r\n\r\nfunction RestartService(growl, NzbHydraControlService, $uibModal) {\r\n\r\n return {\r\n restart: restart,\r\n startCountdown: startCountdown\r\n };\r\n\r\n function restart(message) {\r\n NzbHydraControlService.restart().then(function (response) {\r\n startCountdown(message, response.data.message);\r\n }, function () {\r\n growl.info(\"Unable to send restart command.\");\r\n })\r\n }\r\n\r\n function startCountdown(message, baseUrl) {\r\n $uibModal.open({\r\n templateUrl: 'static/html/restart-modal.html',\r\n controller: RestartModalInstanceCtrl,\r\n size: \"md\",\r\n backdrop: 'static',\r\n keyboard: false,\r\n resolve: {\r\n message: function () {\r\n return message;\r\n },\r\n baseUrl: function () {\r\n return baseUrl;\r\n }\r\n }\r\n });\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('RestartModalInstanceCtrl', RestartModalInstanceCtrl);\r\n\r\nfunction RestartModalInstanceCtrl($scope, $timeout, $http, $window, RequestsErrorHandler, message, baseUrl) {\r\n\r\n message = (angular.isDefined(message) ? message : \"\");\r\n $scope.message = message + \"Will reload page when NZBHydra is back\";\r\n $scope.baseUrl = baseUrl;\r\n $scope.pingUrl = angular.isDefined(baseUrl) ? (baseUrl + \"/internalapi/control/ping\") : \"internalapi/control/ping\";\r\n\r\n $scope.internalCaR = function (message, timer) {\r\n if (timer === 45) {\r\n $scope.message = message + \" Restarting takes longer than expected. You might want to check the log to see what's going on.\";\r\n } else {\r\n $scope.message = message + \" Will reload page when NZBHydra is back.\";\r\n $timeout(function () {\r\n RequestsErrorHandler.specificallyHandled(function () {\r\n $http.get($scope.pingUrl, {ignoreLoadingBar: true}).then(\r\n function () {\r\n $timeout(function () {\r\n $scope.message = \"Reloading page...\";\r\n if (angular.isDefined($scope.baseUrl)) {\r\n $window.location.href = $scope.baseUrl;\r\n } else {\r\n $window.location.reload();\r\n }\r\n }, 2000); //Give Hydra some time to load in the background, it might return the ping but not be completely up yet\r\n }, function () {\r\n $scope.internalCaR(message, timer + 1);\r\n });\r\n });\r\n }, 1000);\r\n $scope.message = message + \" Will reload page when NZBHydra is back.\";\r\n }\r\n };\r\n\r\n //Wait three seconds because otherwise the currently running instance will be found\r\n $timeout(function () {\r\n $scope.internalCaR(message, 0);\r\n }, 3000)\r\n}","\r\nNzbHydraControlService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('NzbHydraControlService', NzbHydraControlService);\r\n\r\nfunction NzbHydraControlService($http) {\r\n\r\n return {\r\n restart: restart,\r\n shutdown: shutdown\r\n };\r\n\r\n function restart() {\r\n return $http.get(\"internalapi/control/restart\");\r\n }\r\n\r\n function shutdown() {\r\n return $http.get(\"internalapi/control/shutdown\");\r\n }\r\n\r\n}\r\n","\r\nNzbDownloadService.$inject = [\"$http\", \"ConfigService\", \"DownloaderCategoriesService\"];angular\r\n .module('nzbhydraApp')\r\n .factory('NzbDownloadService', NzbDownloadService);\r\n\r\nfunction NzbDownloadService($http, ConfigService, DownloaderCategoriesService) {\r\n\r\n var service = {\r\n download: download,\r\n getEnabledDownloaders: getEnabledDownloaders\r\n };\r\n\r\n return service;\r\n\r\n function sendNzbAddCommand(downloader, searchResults, category) {\r\n var params = {\r\n downloaderName: downloader.name,\r\n searchResults: searchResults,\r\n category: category\r\n };\r\n return $http.put(\"internalapi/downloader/addNzbs\", params);\r\n }\r\n\r\n function download(downloader, searchResults, alwaysAsk) {\r\n var category = downloader.defaultCategory;\r\n if (alwaysAsk || (_.isNullOrEmpty(category) && category !== \"Use original category\") && category !== \"Use mapped category\" && category !== \"Use no category\") {\r\n return DownloaderCategoriesService.openCategorySelection(downloader).then(function (category) {\r\n return sendNzbAddCommand(downloader, searchResults, category);\r\n }, function (result) {\r\n return result;\r\n });\r\n } else {\r\n return sendNzbAddCommand(downloader, searchResults, category)\r\n }\r\n }\r\n\r\n function getEnabledDownloaders() {\r\n return _.filter(ConfigService.getSafe().downloading.downloaders, \"enabled\");\r\n }\r\n}\r\n\r\n","\nNotificationService.$inject = [\"$http\"];angular\n .module('nzbhydraApp')\n .service('NotificationService', NotificationService);\n\nfunction NotificationService($http) {\n\n var eventTypesData = {\n AUTH_FAILURE: {\n readable: \"Auth failure\",\n titleTemplate: \"Auth failure\",\n bodyTemplate: \"NZBHydra: A login for username $username$ failed. IP: $ip$.\",\n templateHelp: \"Available variables: $username$, $ip$.\",\n messageType: \"FAILURE\"\n },\n RESULT_DOWNLOAD: {\n readable: \"NZB download\",\n titleTemplate: \"NZB download\",\n bodyTemplate: \"NZBHydra: The result \\\"$title$\\\" was grabbed from indexer $indexerName$.\",\n templateHelp: \"Available variables: $title, $indexerName$, $source$ (NZB or torrent), $age$ ([] for torrents).\",\n messageType: \"INFO\"\n },\n RESULT_DOWNLOAD_COMPLETION: {\n readable: \"Download completion\",\n titleTemplate: \"Download completion\",\n bodyTemplate: \"NZBHydra: Download of \\\"$title$\\\" has finished. Download result: $downloadResult$.\",\n templateHelp: \"Requires the downloading tool to be configured. Available variables: $title, $downloadResult$.\",\n messageType: \"INFO\"\n },\n INDEXER_DISABLED: {\n readable: \"Indexer disabled\",\n titleTemplate: \"Indexer disabled\",\n bodyTemplate: \"NZBHydra: Indexer $indexerName$ was disabled (state: $state$). Message:\\n$message$.\",\n templateHelp: \"Available variables: $indexerName$, $state$, $message$.\",\n messageType: \"WARNING\"\n },\n INDEXER_REENABLED: {\n readable: \"Indexer reenabled after error\",\n titleTemplate: \"Indexer reenabled after error\",\n bodyTemplate: \"NZBHydra: Indexer $indexerName$ was reenabled after a previous error. It had been disabled since $disabledAt$.\",\n templateHelp: \"Available variables: $indexerName$, $disabledAt$.\",\n messageType: \"SUCCESS\"\n },\n UPDATE_INSTALLED: {\n readable: \"Automatic update installed\",\n titleTemplate: \"Update installed\",\n bodyTemplate: \"NZBHydra: A new version of was installed: $version$\",\n templateHelp: \"Available variables: $version$.\",\n messageType: \"SUCCESS\"\n },\n VIP_RENEWAL_REQUIRED: {\n readable: \"VIP renewal required (14 day warning)\",\n titleTemplate: \"VIP renewal required\",\n bodyTemplate: \"NZBHydra: VIP access for indexer $indexerName$ will run out soon: $expirationDate$.\",\n templateHelp: \"Available variables: $indexerName$, $expirationDate$.\",\n messageType: \"WARNING\"\n }\n }\n\n this.getAllEventTypes = function () {\n return _.keys(eventTypesData);\n };\n\n this.getAllData = function () {\n return eventTypesData;\n };\n\n this.humanize = function (eventType) {\n return eventTypesData[eventType].readable;\n };\n\n this.getTemplateHelp = function (eventType) {\n return eventTypesData[eventType].templateHelp;\n };\n\n this.getTitleTemplate = function (eventType) {\n return eventTypesData[eventType].titleTemplate;\n };\n\n this.getBodyTemplate = function (eventType) {\n return eventTypesData[eventType].bodyTemplate;\n };\n\n this.testNotification = function (eventType) {\n return $http.get('internalapi/notifications/test/' + eventType);\n }\n\n\n}","\nNotificationHistoryController.$inject = [\"$scope\", \"StatsService\", \"preloadData\", \"ConfigService\", \"$timeout\", \"NotificationService\"];angular\n .module('nzbhydraApp')\n .controller('NotificationHistoryController', NotificationHistoryController);\n\n\nfunction NotificationHistoryController($scope, StatsService, preloadData, ConfigService, $timeout, NotificationService) {\n $scope.limit = 100;\n $scope.pagination = {\n current: 1\n };\n var sortModel = {\n column: \"time\",\n sortMode: 2\n };\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n }, 10);\n $scope.filterModel = {};\n\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\n\n\n //Preloaded data\n $scope.notifications = preloadData.notifications;\n $scope.totalNotifications = preloadData.totalNotifications;\n\n\n $scope.columnSizes = {\n time: 10,\n type: 15,\n title: 15,\n body: 40,\n urls: 20\n };\n\n $scope.update = function () {\n StatsService.getNotificationHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (data) {\n $scope.notifications = data.notifications;\n $scope.totalNotifications = data.totalNotifications;\n });\n };\n\n\n $scope.eventTypesForFiltering = [];\n var eventTypes = NotificationService.getAllEventTypes();\n _.each(eventTypes, function (key) {\n $scope.eventTypesForFiltering.push({label: NotificationService.humanize(key), id: key})\n })\n\n $scope.$on(\"sort\", function (event, column, sortMode) {\n if (sortMode === 0) {\n column = \"time\";\n sortMode = 2;\n }\n sortModel = {\n column: column,\n sortMode: sortMode\n };\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n $scope.update();\n });\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n $scope.update();\n })\n\n $scope.formatEventType = function (notification) {\n return NotificationService.humanize(notification.notificationEventType);\n };\n\n $scope.formatEventBody = function (notification) {\n return notification.body.replace(\"\\n\", \"
                  \");\n };\n\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateEpoch', reformatDateEpoch);\n\nfunction reformatDateEpoch() {\n return function (date) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm\");\n\n }\n}\n","\r\nModalService.$inject = [\"$uibModal\"];\r\nModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"headline\", \"message\", \"params\", \"textAlign\"];angular\r\n .module('nzbhydraApp')\r\n .factory('ModalService', ModalService);\r\n\r\nfunction ModalService($uibModal) {\r\n\r\n return {\r\n open: open\r\n };\r\n\r\n function open(headline, message, params, size, textAlign) {\r\n //params example:\r\n /*\r\n var p =\r\n {\r\n yes: {\r\n text: \"Yes\", //default: Ok\r\n onYes: function() {}\r\n },\r\n no: { //default: Empty\r\n text: \"No\",\r\n onNo: function () {\r\n }\r\n },\r\n cancel: {\r\n text: \"Cancel\", //default: Cancel\r\n onCancel: function () {\r\n }\r\n }\r\n };\r\n */\r\n if (angular.isUndefined(textAlign)) {\r\n textAlign = \"center\";\r\n }\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'static/html/modal.html',\r\n controller: 'ModalInstanceCtrl',\r\n size: angular.isDefined(size) ? size : \"md\",\r\n resolve: {\r\n headline: function () {\r\n return headline;\r\n },\r\n message: function () {\r\n return message;\r\n },\r\n params: function () {\r\n return params;\r\n },\r\n textAlign: function () {\r\n return textAlign;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then(function () {\r\n\r\n }, function () {\r\n\r\n });\r\n }\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('ModalInstanceCtrl', ModalInstanceCtrl);\r\n\r\nfunction ModalInstanceCtrl($scope, $uibModalInstance, headline, message, params, textAlign) {\r\n\r\n $scope.message = message;\r\n $scope.headline = headline;\r\n $scope.params = params;\r\n $scope.showCancel = angular.isDefined(params) && angular.isDefined(params.cancel);\r\n $scope.showNo = angular.isDefined(params) && angular.isDefined(params.no);\r\n $scope.textAlign = textAlign;\r\n\r\n if (angular.isUndefined(params) || angular.isUndefined(params.yes)) {\r\n $scope.params = {\r\n yes: {\r\n text: \"Ok\"\r\n }\r\n }\r\n } else if (angular.isUndefined(params.yes.text)) {\r\n params.yes.text = \"Yes\";\r\n }\r\n\r\n if (angular.isDefined(params) && angular.isDefined(params.no) && angular.isUndefined($scope.params.no.text)) {\r\n $scope.params.no.text = \"No\";\r\n }\r\n\r\n if (angular.isDefined(params) && angular.isDefined(params.cancel) && angular.isUndefined($scope.params.cancel.text)) {\r\n $scope.params.cancel.text = \"Cancel\";\r\n }\r\n\r\n $scope.yes = function () {\r\n $uibModalInstance.close();\r\n if (angular.isDefined(params) && angular.isDefined(params.yes) && angular.isDefined($scope.params.yes.onYes)) {\r\n $scope.params.yes.onYes();\r\n }\r\n };\r\n\r\n $scope.no = function () {\r\n $uibModalInstance.close();\r\n if (angular.isDefined(params) && angular.isDefined(params.no) && angular.isDefined($scope.params.no.onNo)) {\r\n $scope.params.no.onNo($uibModalInstance);\r\n }\r\n };\r\n\r\n $scope.cancel = function () {\r\n $uibModalInstance.dismiss();\r\n if (angular.isDefined(params.cancel) && angular.isDefined($scope.params.cancel.onCancel)) {\r\n $scope.params.cancel.onCancel();\r\n }\r\n };\r\n\r\n $scope.$on(\"modal.closing\", function (targetScope, reason, c) {\r\n if (reason == \"backdrop click\") {\r\n $scope.cancel();\r\n }\r\n });\r\n}\r\n","angular\n .module('nzbhydraApp')\n .service('GeneralModalService', GeneralModalService);\n\nfunction GeneralModalService() {\n\n\n this.open = function (msg, template, templateUrl, size, data) {\n\n //Prevent circular dependency\n var myInjector = angular.injector([\"ng\", \"ui.bootstrap\"]);\n var $uibModal = myInjector.get(\"$uibModal\");\n var params = {};\n\n if (angular.isUndefined(size)) {\n params[\"size\"] = size;\n }\n if (angular.isUndefined(template)) {\n if (angular.isUndefined(templateUrl)) {\n params[\"template\"] = '
                  ' + msg + '
                  ';\n } else {\n params[\"templateUrl\"] = templateUrl;\n }\n } else {\n params[\"template\"] = template;\n }\n params[\"resolve\"] =\n {\n data: function () {\n return data;\n }\n };\n\n var modalInstance = $uibModal.open(params);\n\n modalInstance.result.then();\n\n };\n\n\n}","\nMigrationService.$inject = [\"$uibModal\"];\nMigrationModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$interval\", \"$http\", \"blockUI\", \"ModalService\"];angular\n .module('nzbhydraApp')\n .factory('MigrationService', MigrationService);\n\nfunction MigrationService($uibModal) {\n\n return {\n migrate: migrate\n };\n\n function migrate() {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/migration-modal.html',\n controller: 'MigrationModalInstanceCtrl',\n size: \"md\",\n backdrop: 'static',\n keyboard: false\n });\n\n modalInstance.result.then(function () {\n ConfigService.reloadConfig();\n }, function () {\n });\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('MigrationModalInstanceCtrl', MigrationModalInstanceCtrl);\n\nfunction MigrationModalInstanceCtrl($scope, $uibModalInstance, $interval, $http, blockUI, ModalService) {\n\n $scope.baseUrl = \"http://127.0.0.1:5075\";\n\n $scope.foo = {isMigrating: false, baseUrl: $scope.baseUrl};\n $scope.doMigrateDatabase = true;\n\n $scope.yes = function () {\n var params;\n var url;\n if ($scope.foo.baseUrl && $scope.foo.isFileBasedOpen) {\n $scope.foo.baseUrl = null;\n }\n\n\n if ($scope.foo.isUrlBasedOpen) {\n url = \"internalapi/migration/url\";\n params = {baseurl: $scope.foo.baseUrl, doMigrateDatabase: $scope.doMigrateDatabase};\n if (!params.baseurl) {\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"You did not enter a URL\", {\n yes: {\n text: \"OK\"\n }\n });\n return;\n }\n } else {\n url = \"internalapi/migration/files\";\n params = {\n settingsCfgFile: $scope.foo.settingsCfgFile,\n dbFile: $scope.foo.nzbhydraDbFile,\n doMigrateDatabase: $scope.doMigrateDatabase\n };\n if (!params.settingsCfgFile || (!params.dbFile && params.doMigrateDatabase)) {\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"You did not enter all required valued\", {\n yes: {\n text: \"OK\"\n }\n });\n return;\n }\n }\n\n $scope.foo.isMigrating = true;\n\n var updateMigrationMessagesInterval = $interval(function () {\n $http.get(\"internalapi/migration/messages\").then(function (response) {\n $scope.foo.messages = response.data;\n },\n function () {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n }\n );\n }, 500);\n\n $http.get(url, {params: params}).then(function (response) {\n var message;\n blockUI.stop();\n var data = response.data;\n if (!data.requirementsMet) {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"An error occurred while preparing the migration:
                  \" + data.error, {\n yes: {\n text: \"OK\"\n }\n });\n } else if (!data.configMigrated) {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n ModalService.open(\"Config migration failed\", \"An error occurred while migrating the config. Migration failed:
                  \" + data.error, {\n yes: {\n text: \"OK\"\n }\n });\n } else if (!data.databaseMigrated) {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n message = \"An error occurred while migrating the database.
                  \" + data.error + \"
                  . The config was migrated successfully though.\";\n if (data.messages.length > 0) {\n message += '

                  The following warnings resulted from the config migration:
                    ';\n _.forEach(data.messages, function (msg) {\n message += \"
                  • \" + msg + \"
                  • \";\n });\n message += \"
                  \";\n }\n ModalService.open(\"Database migration failed\", message, {\n yes: {\n text: \"OK\"\n }\n });\n } else {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n message = \"The migration was completed successfully.\";\n if (data.warningMessages.length > 0) {\n message += '

                  The following warnings resulted from the config migration:
                    ';\n _.forEach(data.warningMessages, function (msg) {\n message += \"
                  • \" + msg + \"
                  • \";\n });\n message += \"
                  \";\n }\n message += \"

                  NZBHydra needs to restart for the changes to be effective.\";\n ModalService.open(\"Migration successful\", message, {\n yes: {\n onYes: function () {\n RestartService.restart();\n },\n text: \"Restart\"\n },\n cancel: {\n onCancel: function () {\n\n },\n text: \"Not now\"\n }\n });\n }\n }, function (response) {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n $scope.foo.messages = [response.data.message];\n }\n );\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMigrationMessagesInterval)) {\n $interval.cancel(updateMigrationMessagesInterval);\n }\n });\n\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n}\n","\r\nLoginController.$inject = [\"$scope\", \"RequestsErrorHandler\", \"$state\", \"HydraAuthService\", \"growl\"];angular\r\n .module('nzbhydraApp')\r\n .controller('LoginController', LoginController);\r\n\r\nfunction LoginController($scope, RequestsErrorHandler, $state, HydraAuthService, growl) {\r\n $scope.user = {};\r\n $scope.login = function () {\r\n RequestsErrorHandler.specificallyHandled(function () {\r\n HydraAuthService.login($scope.user.username, $scope.user.password).then(function () {\r\n HydraAuthService.setLoggedInByForm();\r\n growl.info(\"Login successful!\");\r\n $state.go(\"root.search\");\r\n }, function () {\r\n growl.error(\"Login failed!\")\r\n });\r\n });\r\n }\r\n}\r\n","\nIndexerStatusesController.$inject = [\"$scope\", \"$http\", \"statuses\"];\nformatDate.$inject = [\"dateFilter\"];angular\n .module('nzbhydraApp')\n .controller('IndexerStatusesController', IndexerStatusesController);\n\nfunction IndexerStatusesController($scope, $http, statuses) {\n $scope.statuses = statuses.data;\n $scope.expiryWarnings = {};\n\n $scope.formatState = function (state) {\n if (state === \"ENABLED\") {\n return \"Enabled\";\n } else if (state === \"DISABLED_SYSTEM_TEMPORARY\") {\n return \"Temporarily disabled by system\";\n } else if (state === \"DISABLED_SYSTEM\") {\n return \"Disabled by system\";\n } else {\n return \"Disabled by user\";\n }\n };\n\n $scope.getLabelClass = function (state) {\n if (state === \"ENABLED\") {\n return \"primary\";\n } else if (state === \"DISABLED_SYSTEM_TEMPORARY\") {\n return \"warning\";\n } else if (state === \"DISABLED_SYSTEM\") {\n return \"danger\";\n } else {\n return \"default\";\n }\n };\n\n $scope.isInPast = function (epochSeconds) {\n return epochSeconds < moment().unix();\n };\n\n\n _.each($scope.statuses, function (status) {\n if (status.vipExpirationDate != null && status.vipExpirationDate !== \"Lifetime\") {\n var expiryDate = moment(status.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access\";\n if (expiryDate < moment()) {\n status.expiryWarning = messagePrefix + \" expired\";\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n status.expiryWarning = messagePrefix + \" will expire in the next 7 days\";\n }\n console.log(status.expiryWarning);\n }\n }\n )\n ;\n}\n\nangular\n .module('nzbhydraApp')\n .filter('formatDate', formatDate);\n\nfunction formatDate(dateFilter) {\n return function (timestamp, hidePast) {\n if (timestamp) {\n if (timestamp * 1000 < (new Date).getTime() && hidePast) {\n return \"\"; //\n }\n\n var t = timestamp * 1000;\n t = dateFilter(t, 'yyyy-MM-dd HH:mm');\n return t;\n } else {\n return \"\";\n }\n }\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDate', reformatDate);\n\nfunction reformatDate() {\n return function (date, format) {\n if (!date) {\n return \"\";\n }\n if (angular.isUndefined(format)) {\n format = \"YYYY-MM-DD HH:mm\";\n }\n //Date in database is saved as UTC without timezone information\n return moment.unix(date).local().format(format);\n }\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateSeconds', reformatDateSeconds);\n\nfunction reformatDateSeconds() {\n return function (date, format) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm:ss\");\n }\n}\n\n\nangular\n .module('nzbhydraApp')\n .filter('humanizeDate', humanizeDate);\n\nfunction humanizeDate() {\n return function (date) {\n return moment().to(moment.unix(date));\n }\n}","\r\nIndexController.$inject = [\"$scope\", \"$http\", \"$stateParams\", \"$state\"];angular\r\n .module('nzbhydraApp')\r\n .controller('IndexController', IndexController);\r\n\r\nfunction IndexController($scope, $http, $stateParams, $state) {\r\n\r\n $state.go(\"root.search\");\r\n}\r\n","\r\nHydraAuthService.$inject = [\"$q\", \"$rootScope\", \"$http\", \"bootstrapped\", \"$httpParamSerializerJQLike\", \"$state\"];angular\r\n .module('nzbhydraApp')\r\n .factory('HydraAuthService', HydraAuthService);\r\n\r\nfunction HydraAuthService($q, $rootScope, $http, bootstrapped, $httpParamSerializerJQLike, $state) {\r\n\r\n var loggedIn = bootstrapped.username;\r\n\r\n\r\n return {\r\n isLoggedIn: isLoggedIn,\r\n login: login,\r\n askForPassword: askForPassword,\r\n logout: logout,\r\n setLoggedInByForm: setLoggedInByForm,\r\n getUserRights: getUserRights,\r\n setLoggedInByBasic: setLoggedInByBasic,\r\n getUserName: getUserName,\r\n getUserInfos: getUserInfos\r\n };\r\n\r\n function getUserInfos() {\r\n return bootstrapped;\r\n }\r\n\r\n function isLoggedIn() {\r\n return bootstrapped.username;\r\n }\r\n\r\n function setLoggedInByForm() {\r\n $rootScope.$broadcast(\"user:loggedIn\");\r\n }\r\n\r\n\r\n function setLoggedInByBasic(_maySeeStats, _maySeeAdmin, _username) {\r\n }\r\n\r\n function login(username, password) {\r\n var deferred = $q.defer();\r\n //return $http.post(\"login\", data = {username: username, password: password})\r\n return $http({\r\n url: \"login\",\r\n method: \"POST\",\r\n headers: {\r\n 'Content-Type': 'application/x-www-form-urlencoded' // Note the appropriate header\r\n },\r\n data: $httpParamSerializerJQLike({username: username, password: password})\r\n })\r\n .then(function () {\r\n $http.get(\"internalapi/userinfos\").then(function (data) {\r\n bootstrapped = data.data;\r\n loggedIn = true;\r\n $rootScope.$broadcast(\"user:loggedIn\");\r\n deferred.resolve();\r\n });\r\n });\r\n }\r\n\r\n function askForPassword(params) {\r\n return $http.get(\"internalapi/askpassword\", {params: params}).then(function (data) {\r\n bootstrapped = data.data;\r\n return bootstrapped;\r\n });\r\n }\r\n\r\n function logout() {\r\n var deferred = $q.defer();\r\n return $http.post(\"logout\").then(function () {\r\n $http.get(\"internalapi/userinfos\").then(function (data) {\r\n bootstrapped = data.data;\r\n $rootScope.$broadcast(\"user:loggedOut\");\r\n loggedIn = false;\r\n if (bootstrapped.maySeeSearch) {\r\n $state.go(\"root.search\");\r\n } else {\r\n $state.go(\"root.login\");\r\n }\r\n //window.location.reload(false);\r\n deferred.resolve();\r\n });\r\n });\r\n }\r\n\r\n function getUserRights() {\r\n var userInfos = getUserInfos();\r\n return {\r\n maySeeStats: userInfos.maySeeStats,\r\n maySeeAdmin: userInfos.maySeeAdmin,\r\n maySeeSearch: userInfos.maySeeSearch\r\n };\r\n }\r\n\r\n function getUserName() {\r\n return bootstrapped.username;\r\n }\r\n\r\n\r\n}","\nHeaderController.$inject = [\"$scope\", \"$state\", \"growl\", \"HydraAuthService\", \"bootstrapped\"];angular\n .module('nzbhydraApp')\n .controller('HeaderController', HeaderController);\n\nfunction HeaderController($scope, $state, growl, HydraAuthService, bootstrapped) {\n\n\n $scope.showLoginout = false;\n $scope.oldUserName = null;\n $scope.bootstrapped = bootstrapped;\n\n function update(event) {\n\n $scope.userInfos = HydraAuthService.getUserInfos();\n if (!$scope.userInfos.authConfigured) {\n $scope.showSearch = true;\n $scope.showAdmin = true;\n $scope.showStats = true;\n $scope.showLoginout = false;\n } else {\n if ($scope.userInfos.username) {\n $scope.showSearch = true;\n $scope.showAdmin = $scope.userInfos.maySeeAdmin || !$scope.userInfos.adminRestricted;\n $scope.showStats = $scope.userInfos.maySeeStats || !$scope.userInfos.statsRestricted;\n $scope.showLoginout = true;\n $scope.username = $scope.userInfos.username;\n $scope.loginlogoutText = \"Logout \" + $scope.username;\n $scope.oldUserName = $scope.username;\n } else {\n $scope.showAdmin = !$scope.userInfos.adminRestricted;\n $scope.showStats = !$scope.userInfos.statsRestricted;\n $scope.showSearch = !$scope.userInfos.searchRestricted;\n $scope.loginlogoutText = \"Login\";\n $scope.showLoginout = ($scope.userInfos.adminRestricted || $scope.userInfos.statsRestricted || $scope.userInfos.searchRestricted) && event !== \"loggedOut\" && !$state.is(\"root.login\");\n $scope.username = \"\";\n }\n }\n }\n\n update();\n\n\n $scope.$on(\"user:loggedIn\", function (event, data) {\n update(\"loggedIn\");\n });\n\n $scope.$on(\"user:loggedOut\", function (event, data) {\n update(\"loggedOut\");\n });\n\n $scope.loginout = function () {\n if (HydraAuthService.isLoggedIn()) {\n HydraAuthService.logout().then(function () {\n if ($scope.userInfos.authType === \"BASIC\") {\n growl.info(\"Logged out. Close your browser to make sure session is closed.\");\n }\n else if ($scope.userInfos.authType === \"FORM\") {\n growl.info(\"Logged out\");\n }\n update();\n //$state.go(\"root.search\", null, {reload: true});\n });\n\n } else {\n if ($scope.userInfos.authType === \"BASIC\") {\n var params = {};\n if ($scope.oldUserName) {\n params = {\n old_username: $scope.oldUserName\n }\n }\n HydraAuthService.askForPassword(params).then(function () {\n growl.info(\"Login successful!\");\n $scope.oldUserName = null;\n update(\"loggedIn\");\n $state.go(\"root.search\");\n })\n } else if ($scope.userInfos.authType === \"FORM\") {\n $state.go(\"root.login\");\n } else {\n growl.info(\"You shouldn't need to login but here you go!\");\n }\n }\n\n };\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n//\nGenericStorageService.$inject = [\"$http\"];\nangular\n .module('nzbhydraApp')\n .factory('GenericStorageService', GenericStorageService);\n\nfunction GenericStorageService($http) {\n\n return {\n get: get,\n put: put\n };\n\n function get(key, forUser) {\n return $http.get(\"internalapi/genericstorage/\" + key, {params: {forUser: forUser}, ignoreLoadingBar: true});\n }\n\n function put(key, forUser, value) {\n return $http.put(\"internalapi/genericstorage/\" + key, value, {params: {forUser: forUser}, ignoreLoadingBar: true});\n }\n\n\n}","var HEADER_NAME = 'NzbHydra2-Handle-Errors-Generically';\nvar specificallyHandleInProgress = false;\n\nnzbhydraapp.factory('RequestsErrorHandler', [\"$q\", \"growl\", \"blockUI\", \"GeneralModalService\", function ($q, growl, blockUI, GeneralModalService) {\n return {\n // --- The user's API for claiming responsiblity for requests ---\n specificallyHandled: function (specificallyHandledBlock) {\n specificallyHandleInProgress = true;\n try {\n return specificallyHandledBlock();\n } finally {\n specificallyHandleInProgress = false;\n }\n },\n\n // --- Response interceptor for handling errors generically ---\n responseError: function (rejection) {\n blockUI.reset();\n if (rejection.data instanceof ArrayBuffer) {\n //The case when the response was specifically requested as that, e.g. for debug infos\n rejection.data = JSON.parse(new TextDecoder().decode(rejection.data));\n }\n var shouldHandle = (rejection && rejection.config && rejection.status !== 403 && rejection.config.headers && rejection.config.headers[HEADER_NAME] && !rejection.config.url.contains(\"logerror\") && !rejection.config.url.contains(\"/ping\") && !rejection.config.alreadyHandled);\n if (shouldHandle) {\n if (rejection.data) {\n\n var message = \"An error occurred:
                  \" + rejection.data.status;\n if (rejection.data.error) {\n message += \": \" + rejection.data.error\n }\n if (rejection.data.path) {\n message += \"

                  Path: \" + rejection.data.path;\n }\n if (message !== \"No message available\") {\n message += \"

                  Message: \" + _.escape(rejection.data.message);\n } else {\n message += \"

                  Exception: \" + rejection.data.exception;\n }\n } else {\n message = \"An unknown error occurred while communicating with NZBHydra:

                  \" + JSON.stringify(rejection);\n }\n GeneralModalService.open(message);\n\n } else if (rejection && rejection.config && rejection.config.headers && rejection.config.headers[HEADER_NAME] && rejection.config.url.contains(\"logerror\")) {\n console.log(\"Not handling connection error while sending exception to server\");\n }\n return $q.reject(rejection);\n }\n };\n}]);\n\nnzbhydraapp.config(['$provide', '$httpProvider', function ($provide, $httpProvider) {\n $httpProvider.interceptors.push('RequestsErrorHandler');\n\n // --- Decorate $http to add a special header by default ---\n\n function addHeaderToConfig(config) {\n config = config || {};\n config.headers = config.headers || {};\n\n // Add the header unless user asked to handle errors himself\n if (!specificallyHandleInProgress) {\n config.headers[HEADER_NAME] = true;\n }\n\n return config;\n }\n\n // The rest here is mostly boilerplate needed to decorate $http safely\n $provide.decorator('$http', ['$delegate', function ($delegate) {\n function decorateRegularCall(method) {\n return function (url, config) {\n return $delegate[method](url, addHeaderToConfig(config));\n };\n }\n\n function decorateDataCall(method) {\n return function (url, data, config) {\n return $delegate[method](url, data, addHeaderToConfig(config));\n };\n }\n\n function copyNotOverriddenAttributes(newHttp) {\n for (var attr in $delegate) {\n if (!newHttp.hasOwnProperty(attr)) {\n if (typeof($delegate[attr]) === 'function') {\n newHttp[attr] = function () {\n return $delegate.apply($delegate, arguments);\n };\n } else {\n newHttp[attr] = $delegate[attr];\n }\n }\n }\n }\n\n var newHttp = function (config) {\n return $delegate(addHeaderToConfig(config));\n };\n\n newHttp.get = decorateRegularCall('get');\n newHttp.delete = decorateRegularCall('delete');\n newHttp.head = decorateRegularCall('head');\n newHttp.jsonp = decorateRegularCall('jsonp');\n newHttp.post = decorateDataCall('post');\n newHttp.put = decorateDataCall('put');\n\n copyNotOverriddenAttributes(newHttp);\n\n return newHttp;\n }]);\n}]);\n","var filters = angular.module('filters', []);\n\nfilters.filter('bytes', function () {\n return function (bytes) {\n return filesize(bytes);\n }\n});\n\nfilters\n .filter('unsafe', ['$sce', function ($sce) {\n return function (text) {\n return $sce.trustAsHtml(text);\n };\n }]);\n\n","\r\nFileSelectionService.$inject = [\"$http\", \"$q\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .factory('FileSelectionService', FileSelectionService);\r\n\r\nfunction FileSelectionService($http, $q, $uibModal) {\r\n\r\n var categories = {};\r\n var selectedCategory = {};\r\n\r\n var service = {\r\n open: open\r\n };\r\n\r\n var deferred;\r\n\r\n return service;\r\n\r\n\r\n function open(fullPath, type) {\r\n var instance = $uibModal.open({\r\n templateUrl: 'static/html/file-selection.html',\r\n controller: 'FileSelectionModalController',\r\n size: \"md\",\r\n resolve: {\r\n data: function () {\r\n return $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: angular.isDefined(fullPath) ? fullPath : null,\r\n goUp: false,\r\n type: type\r\n });\r\n },\r\n type: function () {\r\n return type;\r\n }\r\n }\r\n });\r\n\r\n instance.result.then(function (selection) {\r\n deferred.resolve(selection);\r\n }, function () {\r\n deferred.reject(\"dismissed\");\r\n }\r\n );\r\n deferred = $q.defer();\r\n return deferred.promise;\r\n }\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').controller('FileSelectionModalController', [\"$scope\", \"$http\", \"$uibModalInstance\", \"FileSelectionService\", \"data\", \"type\", function ($scope, $http, $uibModalInstance, FileSelectionService, data, type) {\r\n\r\n $scope.type = type;\r\n $scope.showType = type === \"file\" ? \"File\" : \"Folder\";\r\n $scope.data = data.data;\r\n\r\n $scope.select = function (fileOrFolder, selectType) {\r\n if (selectType === \"file\" && type === \"file\") {\r\n $uibModalInstance.close(fileOrFolder.fullPath);\r\n } else if (selectType === \"folder\") {\r\n $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: fileOrFolder.fullPath,\r\n type: type,\r\n goUp: false\r\n }).then(function (data) {\r\n $scope.data = data.data;\r\n })\r\n }\r\n };\r\n\r\n $scope.goUp = function () {\r\n $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: $scope.data.fullPath,\r\n type: type,\r\n goUp: true\r\n }).then(function (data) {\r\n $scope.data = data.data;\r\n })\r\n };\r\n\r\n $scope.submit = function () {\r\n $uibModalInstance.close($scope.data.fullPath);\r\n }\r\n\r\n}]);","\r\nFileDownloadService.$inject = [\"$http\", \"growl\"];angular\r\n .module('nzbhydraApp')\r\n .factory('FileDownloadService', FileDownloadService);\r\n\r\nfunction FileDownloadService($http, growl) {\r\n\r\n var service = {\r\n downloadFile: downloadFile\r\n };\r\n\r\n return service;\r\n\r\n function downloadFile(link, filename, method, data) {\r\n return $http({\r\n method: method,\r\n url: link,\r\n data: data,\r\n responseType: 'arraybuffer'\r\n }).then(function (response, status, headers, config) {\r\n var a = document.createElement('a');\r\n var blob = new Blob([response.data], {'type': \"application/octet-stream\"});\r\n a.href = URL.createObjectURL(blob);\r\n a.download = filename;\r\n\r\n document.body.appendChild(a);\r\n a.click();\r\n document.body.removeChild(a);\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n\r\n }\r\n\r\n\r\n}\r\n\r\n","\r\nDownloaderCategoriesService.$inject = [\"$http\", \"$q\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .factory('DownloaderCategoriesService', DownloaderCategoriesService);\r\n\r\nfunction DownloaderCategoriesService($http, $q, $uibModal) {\r\n\r\n var categories = {};\r\n var selectedCategory = {};\r\n\r\n var service = {\r\n get: getCategories,\r\n invalidate: invalidate,\r\n select: select,\r\n openCategorySelection: openCategorySelection\r\n };\r\n\r\n var deferred;\r\n\r\n return service;\r\n\r\n function getCategories(downloader) {\r\n function loadAll() {\r\n if (downloader.name in categories) {\r\n var deferred = $q.defer();\r\n deferred.resolve(categories[downloader.name]);\r\n return deferred.promise;\r\n }\r\n\r\n return $http.get(encodeURI('internalapi/downloader/' + downloader.name + \"/categories\"))\r\n .then(function (categoriesResponse) {\r\n categories[downloader.name] = categoriesResponse.data;\r\n return categoriesResponse.data;\r\n\r\n }, function (error) {\r\n throw error;\r\n });\r\n }\r\n\r\n return loadAll().then(function (categories) {\r\n return categories;\r\n }, function (error) {\r\n throw error;\r\n });\r\n }\r\n\r\n\r\n function openCategorySelection(downloader) {\r\n var instance = $uibModal.open({\r\n templateUrl: 'static/html/directives/addable-nzb-modal.html',\r\n controller: 'DownloaderCategorySelectionController',\r\n size: \"sm\",\r\n resolve: {\r\n categories: function () {\r\n return getCategories(downloader)\r\n }\r\n }\r\n });\r\n\r\n instance.result.then(function () {\r\n }, function () {\r\n deferred.reject(\"dismissed\");\r\n }\r\n );\r\n deferred = $q.defer();\r\n return deferred.promise;\r\n }\r\n\r\n function select(category) {\r\n selectedCategory = category;\r\n\r\n deferred.resolve(category);\r\n }\r\n\r\n function invalidate() {\r\n categories = {};\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').controller('DownloaderCategorySelectionController', [\"$scope\", \"$uibModalInstance\", \"DownloaderCategoriesService\", \"categories\", function ($scope, $uibModalInstance, DownloaderCategoriesService, categories) {\r\n\r\n $scope.categories = categories;\r\n categories.sort();\r\n console.log(categories);\r\n $scope.select = function (category) {\r\n DownloaderCategoriesService.select(category);\r\n $uibModalInstance.close($scope);\r\n }\r\n}]);","\nDownloadHistoryController.$inject = [\"$scope\", \"StatsService\", \"downloads\", \"ConfigService\", \"$timeout\", \"$sce\"];angular\n .module('nzbhydraApp')\n .controller('DownloadHistoryController', DownloadHistoryController);\n\n\nfunction DownloadHistoryController($scope, StatsService, downloads, ConfigService, $timeout, $sce) {\n $scope.limit = 100;\n $scope.pagination = {\n current: 1\n };\n var sortModel = {\n column: \"time\",\n sortMode: 2\n };\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n }, 10);\n $scope.filterModel = {};\n\n //Filter options\n $scope.indexersForFiltering = [];\n _.forEach(ConfigService.getSafe().indexers, function (indexer) {\n $scope.indexersForFiltering.push({label: indexer.name, id: indexer.name})\n });\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\n $scope.statusesForFiltering = [\n {label: \"None\", id: 'NONE'},\n {label: \"Requested\", id: 'REQUESTED'},\n {label: \"Internal error\", id: 'INTERNAL_ERROR'},\n {label: \"NZB downloaded successful\", id: 'NZB_DOWNLOAD_SUCCESSFUL'},\n {label: \"NZB download error\", id: 'NZB_DOWNLOAD_ERROR'},\n {label: \"NZB added\", id: 'NZB_ADDED'},\n {label: \"NZB not added\", id: 'NZB_NOT_ADDED'},\n {label: \"NZB add error\", id: 'NZB_ADD_ERROR'},\n {label: \"NZB add rejected\", id: 'NZB_ADD_REJECTED'},\n {label: \"Content download successful\", id: 'CONTENT_DOWNLOAD_SUCCESSFUL'},\n {label: \"Content download warning\", id: 'CONTENT_DOWNLOAD_WARNING'},\n {label: \"Content download error\", id: 'CONTENT_DOWNLOAD_ERROR'}\n ];\n $scope.accessOptionsForFiltering = [{label: \"All\", value: \"all\"}, {label: \"API\", value: 'API'}, {\n label: \"Internal\",\n value: 'INTERNAL'\n }];\n\n //Preloaded data\n $scope.nzbDownloads = downloads.nzbDownloads;\n $scope.totalDownloads = downloads.totalDownloads;\n\n $scope.columnSizes = {\n time: 10,\n indexer: 10,\n title: 37,\n result: 9,\n source: 8,\n age: 6,\n username: 10,\n ip: 10\n };\n var anyUsername = false;\n var anyIp = false;\n for (var download of $scope.nzbDownloads) {\n if (download.username) {\n anyUsername = true;\n }\n if (download.ip) {\n anyIp = true;\n }\n if (anyIp && anyUsername) {\n break;\n }\n }\n\n if (ConfigService.getSafe().logging.historyUserInfoType === \"NONE\" || (!anyUsername && !anyIp)) {\n $scope.columnSizes.username = 0;\n $scope.columnSizes.ip = 0;\n $scope.columnSizes.title += 20;\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"IP\") {\n $scope.columnSizes.username = 0;\n $scope.columnSizes.title += 10;\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"USERNAME\") {\n $scope.columnSizes.ip = 0;\n $scope.columnSizes.title += 10;\n }\n\n $scope.update = function () {\n StatsService.getDownloadHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (downloads) {\n $scope.nzbDownloads = downloads.nzbDownloads;\n $scope.totalDownloads = downloads.totalDownloads;\n });\n };\n\n\n $scope.$on(\"sort\", function (event, column, sortMode) {\n if (sortMode === 0) {\n column = \"time\";\n sortMode = 2;\n }\n sortModel = {\n column: column,\n sortMode: sortMode\n };\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n $scope.update();\n });\n\n $scope.getStatusIcon = function (result) {\n var spans;\n if (result === \"NONE\" || result === \"REQUESTED\") {\n spans = ''\n }\n if (result === \"INTERNAL_ERROR\") {\n spans = ''\n }\n if (result === \"INTERNAL_ERROR\") {\n spans = ''\n }\n if (result === 'NZB_DOWNLOAD_SUCCESSFUL') {\n spans = '';\n }\n if (result === 'NZB_DOWNLOAD_ERROR') {\n spans = '';\n }\n if (result === 'NZB_ADDED') {\n spans = '';\n }\n if (result === 'NZB_NOT_ADDED' || result === 'NZB_ADD_ERROR' || result === 'NZB_ADD_REJECTED') {\n spans = '';\n }\n if (result === 'CONTENT_DOWNLOAD_SUCCESSFUL') {\n spans = '';\n }\n if (result === 'CONTENT_DOWNLOAD_ERROR' || result === 'CONTENT_DOWNLOAD_WARNING') {\n spans = '';\n }\n return $sce.trustAsHtml('' + spans + '');\n\n };\n\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n $scope.update();\n })\n\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateEpoch', reformatDateEpoch);\n\nfunction reformatDateEpoch() {\n return function (date) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm\");\n\n }\n}\n","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nDebugService.$inject = [\"$filter\"];\r\nangular\r\n .module('nzbhydraApp')\r\n .factory('DebugService', DebugService);\r\n\r\nfunction DebugService($filter) {\r\n\r\n var debug = {};\r\n\r\n return {\r\n log: log,\r\n print: print\r\n };\r\n\r\n function log(name) {\r\n if (!(name in debug)) {\r\n debug[name] = {first: new Date().getTime(), last: new Date().getTime()};\r\n } else {\r\n debug[name][\"last\"] = new Date().getTime();\r\n }\r\n }\r\n\r\n function print() {\r\n //Re-enable if necessary\r\n // for (var key in debug) {\r\n // if (debug.hasOwnProperty(key)) {\r\n // console.log(\"First \" + key + \": \" + $filter(\"date\")(new Date(debug[key][\"first\"]), \"h:mm:ss:sss\"));\r\n // console.log(\"Last \" + key + \": \" + $filter(\"date\")(new Date(debug[key][\"last\"]), \"h:mm:ss:sss\"));\r\n // console.log(\"Diff: \" + (debug[key][\"last\"] - debug[key][\"first\"]));\r\n // }\r\n // }\r\n }\r\n\r\n\r\n}","\r\nCategoriesService.$inject = [\"ConfigService\"];angular\r\n .module('nzbhydraApp')\r\n .factory('CategoriesService', CategoriesService);\r\n\r\nfunction CategoriesService(ConfigService) {\r\n\r\n return {\r\n getByName: getByName,\r\n getAllCategories: getAllCategories,\r\n getDefault: getDefault,\r\n getWithoutAll: getWithoutAll\r\n };\r\n\r\n\r\n function getByName(name) {\r\n for (var cat in ConfigService.getSafe().categoriesConfig.categories) {\r\n var category = ConfigService.getSafe().categoriesConfig.categories[cat];\r\n if (category.name === name) {\r\n return category;\r\n }\r\n }\r\n }\r\n\r\n function getAllCategories() {\r\n return ConfigService.getSafe().categoriesConfig.categories;\r\n }\r\n\r\n function getWithoutAll() {\r\n var cats = ConfigService.getSafe().categoriesConfig.categories;\r\n return cats.slice(1, cats.length);\r\n }\r\n\r\n function getDefault() {\r\n return getByName(ConfigService.getSafe().categoriesConfig.defaultCategory);\r\n }\r\n\r\n}","\r\nBackupService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('BackupService', BackupService);\r\n\r\nfunction BackupService($http) {\r\n\r\n return {\r\n getBackupsList: getBackupsList,\r\n restoreFromFile: restoreFromFile\r\n };\r\n\r\n\r\n function getBackupsList() {\r\n return $http.get('internalapi/backup/list').then(function (response) {\r\n return response.data;\r\n });\r\n }\r\n\r\n function restoreFromFile(filename) {\r\n return $http.get('internalapi/backup/restore', {params: {filename: filename}}).then(function (response) {\r\n return response;\r\n });\r\n }\r\n\r\n}","//Copied from https://github.com/oblador/angular-scroll because installing it via bower caused errors\nvar duScrollDefaultEasing = function (x) {\n\n\n if (x < 0.5) {\n return Math.pow(x * 2, 2) / 2;\n }\n return 1 - Math.pow((1 - x) * 2, 2) / 2;\n};\n\nvar duScroll = angular.module('duScroll', [\n 'duScroll.scrollspy',\n 'duScroll.smoothScroll',\n 'duScroll.scrollContainer',\n 'duScroll.spyContext',\n 'duScroll.scrollHelpers'\n])\n//Default animation duration for smoothScroll directive\n .value('duScrollDuration', 350)\n //Scrollspy debounce interval, set to 0 to disable\n .value('duScrollSpyWait', 100)\n //Scrollspy forced refresh interval, use if your content changes or reflows without scrolling.\n //0 to disable\n .value('duScrollSpyRefreshInterval', 0)\n //Wether or not multiple scrollspies can be active at once\n .value('duScrollGreedy', false)\n //Default offset for smoothScroll directive\n .value('duScrollOffset', 0)\n //Default easing function for scroll animation\n .value('duScrollEasing', duScrollDefaultEasing)\n //Which events on the container (such as body) should cancel scroll animations\n .value('duScrollCancelOnEvents', 'scroll mousedown mousewheel touchmove keydown')\n //Whether or not to activate the last scrollspy, when page/container bottom is reached\n .value('duScrollBottomSpy', false)\n //Active class name\n .value('duScrollActiveClass', 'active');\n\nif (typeof module !== 'undefined' && module && module.exports) {\n module.exports = duScroll;\n}\n\n\nangular.module('duScroll.scrollHelpers', ['duScroll.requestAnimation'])\n .run([\"$window\", \"$q\", \"cancelAnimation\", \"requestAnimation\", \"duScrollEasing\", \"duScrollDuration\", \"duScrollOffset\", \"duScrollCancelOnEvents\", function ($window, $q, cancelAnimation, requestAnimation, duScrollEasing, duScrollDuration, duScrollOffset, duScrollCancelOnEvents) {\n 'use strict';\n\n var proto = {};\n\n var isDocument = function (el) {\n return (typeof HTMLDocument !== 'undefined' && el instanceof HTMLDocument) || (el.nodeType && el.nodeType === el.DOCUMENT_NODE);\n };\n\n var isElement = function (el) {\n return (typeof HTMLElement !== 'undefined' && el instanceof HTMLElement) || (el.nodeType && el.nodeType === el.ELEMENT_NODE);\n };\n\n var unwrap = function (el) {\n return isElement(el) || isDocument(el) ? el : el[0];\n };\n\n proto.duScrollTo = function (left, top, duration, easing) {\n var aliasFn;\n if (angular.isElement(left)) {\n aliasFn = this.duScrollToElement;\n } else if (angular.isDefined(duration)) {\n aliasFn = this.duScrollToAnimated;\n }\n if (aliasFn) {\n return aliasFn.apply(this, arguments);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollTo(left, top);\n }\n el.scrollLeft = left;\n el.scrollTop = top;\n };\n\n var scrollAnimation, deferred;\n proto.duScrollToAnimated = function (left, top, duration, easing) {\n if (duration && !easing) {\n easing = duScrollEasing;\n }\n var startLeft = this.duScrollLeft(),\n startTop = this.duScrollTop(),\n deltaLeft = Math.round(left - startLeft),\n deltaTop = Math.round(top - startTop);\n\n var startTime = null, progress = 0;\n var el = this;\n\n var cancelScrollAnimation = function ($event) {\n if (!$event || (progress && $event.which > 0)) {\n if (duScrollCancelOnEvents) {\n el.unbind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n cancelAnimation(scrollAnimation);\n deferred.reject();\n scrollAnimation = null;\n }\n };\n\n if (scrollAnimation) {\n cancelScrollAnimation();\n }\n deferred = $q.defer();\n\n if (duration === 0 || (!deltaLeft && !deltaTop)) {\n if (duration === 0) {\n el.duScrollTo(left, top);\n }\n deferred.resolve();\n return deferred.promise;\n }\n\n var animationStep = function (timestamp) {\n if (startTime === null) {\n startTime = timestamp;\n }\n\n progress = timestamp - startTime;\n var percent = (progress >= duration ? 1 : easing(progress / duration));\n\n el.scrollTo(\n startLeft + Math.ceil(deltaLeft * percent),\n startTop + Math.ceil(deltaTop * percent)\n );\n if (percent < 1) {\n scrollAnimation = requestAnimation(animationStep);\n } else {\n if (duScrollCancelOnEvents) {\n el.unbind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n scrollAnimation = null;\n deferred.resolve();\n }\n };\n\n //Fix random mobile safari bug when scrolling to top by hitting status bar\n el.duScrollTo(startLeft, startTop);\n\n if (duScrollCancelOnEvents) {\n el.bind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n\n scrollAnimation = requestAnimation(animationStep);\n return deferred.promise;\n };\n\n proto.duScrollToElement = function (target, offset, duration, easing) {\n var el = unwrap(this);\n if (!angular.isNumber(offset) || isNaN(offset)) {\n offset = duScrollOffset;\n }\n var top = this.duScrollTop() + unwrap(target).getBoundingClientRect().top - offset;\n if (isElement(el)) {\n top -= el.getBoundingClientRect().top;\n }\n return this.duScrollTo(0, top, duration, easing);\n };\n\n proto.duScrollLeft = function (value, duration, easing) {\n if (angular.isNumber(value)) {\n return this.duScrollTo(value, this.duScrollTop(), duration, easing);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft;\n }\n return el.scrollLeft;\n };\n proto.duScrollTop = function (value, duration, easing) {\n if (angular.isNumber(value)) {\n return this.duScrollTo(this.duScrollLeft(), value, duration, easing);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;\n }\n return el.scrollTop;\n };\n\n proto.duScrollToElementAnimated = function (target, offset, duration, easing) {\n return this.duScrollToElement(target, offset, duration || duScrollDuration, easing);\n };\n\n proto.duScrollTopAnimated = function (top, duration, easing) {\n return this.duScrollTop(top, duration || duScrollDuration, easing);\n };\n\n proto.duScrollLeftAnimated = function (left, duration, easing) {\n return this.duScrollLeft(left, duration || duScrollDuration, easing);\n };\n\n angular.forEach(proto, function (fn, key) {\n angular.element.prototype[key] = fn;\n\n //Remove prefix if not already claimed by jQuery / ui.utils\n var unprefixed = key.replace(/^duScroll/, 'scroll');\n if (angular.isUndefined(angular.element.prototype[unprefixed])) {\n angular.element.prototype[unprefixed] = fn;\n }\n });\n\n }]);\n\n\n//Adapted from https://gist.github.com/paulirish/1579671\nangular.module('duScroll.polyfill', [])\n .factory('polyfill', [\"$window\", function ($window) {\n 'use strict';\n\n var vendors = ['webkit', 'moz', 'o', 'ms'];\n\n return function (fnName, fallback) {\n if ($window[fnName]) {\n return $window[fnName];\n }\n var suffix = fnName.substr(0, 1).toUpperCase() + fnName.substr(1);\n for (var key, i = 0; i < vendors.length; i++) {\n key = vendors[i] + suffix;\n if ($window[key]) {\n return $window[key];\n }\n }\n return fallback;\n };\n }]);\n\nangular.module('duScroll.requestAnimation', ['duScroll.polyfill'])\n .factory('requestAnimation', [\"polyfill\", \"$timeout\", function (polyfill, $timeout) {\n 'use strict';\n\n var lastTime = 0;\n var fallback = function (callback, element) {\n var currTime = new Date().getTime();\n var timeToCall = Math.max(0, 16 - (currTime - lastTime));\n var id = $timeout(function () {\n callback(currTime + timeToCall);\n },\n timeToCall);\n lastTime = currTime + timeToCall;\n return id;\n };\n\n return polyfill('requestAnimationFrame', fallback);\n }])\n .factory('cancelAnimation', [\"polyfill\", \"$timeout\", function (polyfill, $timeout) {\n 'use strict';\n\n var fallback = function (promise) {\n $timeout.cancel(promise);\n };\n\n return polyfill('cancelAnimationFrame', fallback);\n }]);\n\n\nangular.module('duScroll.spyAPI', ['duScroll.scrollContainerAPI'])\n .factory('spyAPI', [\"$rootScope\", \"$timeout\", \"$interval\", \"$window\", \"$document\", \"scrollContainerAPI\", \"duScrollGreedy\", \"duScrollSpyWait\", \"duScrollSpyRefreshInterval\", \"duScrollBottomSpy\", \"duScrollActiveClass\", function ($rootScope, $timeout, $interval, $window, $document, scrollContainerAPI, duScrollGreedy, duScrollSpyWait, duScrollSpyRefreshInterval, duScrollBottomSpy, duScrollActiveClass) {\n 'use strict';\n\n var createScrollHandler = function (context) {\n var timer = false, queued = false;\n var handler = function () {\n queued = false;\n var container = context.container,\n containerEl = container[0],\n containerOffset = 0,\n bottomReached;\n\n if (typeof HTMLElement !== 'undefined' && containerEl instanceof HTMLElement || containerEl.nodeType && containerEl.nodeType === containerEl.ELEMENT_NODE) {\n containerOffset = containerEl.getBoundingClientRect().top;\n bottomReached = Math.round(containerEl.scrollTop + containerEl.clientHeight) >= containerEl.scrollHeight;\n } else {\n var documentScrollHeight = $document[0].body.scrollHeight || $document[0].documentElement.scrollHeight; // documentElement for IE11\n bottomReached = Math.round($window.pageYOffset + $window.innerHeight) >= documentScrollHeight;\n }\n var compareProperty = (duScrollBottomSpy && bottomReached ? 'bottom' : 'top');\n\n var i, currentlyActive, toBeActive, spies, spy, pos;\n spies = context.spies;\n currentlyActive = context.currentlyActive;\n toBeActive = undefined;\n\n for (i = 0; i < spies.length; i++) {\n spy = spies[i];\n pos = spy.getTargetPosition();\n if (!pos || !spy.$element) continue;\n\n if ((duScrollBottomSpy && bottomReached) || (pos.top + spy.offset - containerOffset < 20 && (duScrollGreedy || pos.top * -1 + containerOffset) < pos.height)) {\n //Find the one closest the viewport top or the page bottom if it's reached\n if (!toBeActive || toBeActive[compareProperty] < pos[compareProperty]) {\n toBeActive = {\n spy: spy\n };\n toBeActive[compareProperty] = pos[compareProperty];\n }\n }\n }\n\n if (toBeActive) {\n toBeActive = toBeActive.spy;\n }\n if (currentlyActive === toBeActive || (duScrollGreedy && !toBeActive)) return;\n if (currentlyActive && currentlyActive.$element) {\n currentlyActive.$element.removeClass(duScrollActiveClass);\n $rootScope.$broadcast(\n 'duScrollspy:becameInactive',\n currentlyActive.$element,\n angular.element(currentlyActive.getTargetElement())\n );\n }\n if (toBeActive) {\n toBeActive.$element.addClass(duScrollActiveClass);\n $rootScope.$broadcast(\n 'duScrollspy:becameActive',\n toBeActive.$element,\n angular.element(toBeActive.getTargetElement())\n );\n }\n context.currentlyActive = toBeActive;\n };\n\n if (!duScrollSpyWait) {\n return handler;\n }\n\n //Debounce for potential performance savings\n return function () {\n if (!timer) {\n handler();\n timer = $timeout(function () {\n timer = false;\n if (queued) {\n handler();\n }\n }, duScrollSpyWait, false);\n } else {\n queued = true;\n }\n };\n };\n\n var contexts = {};\n\n var createContext = function ($scope) {\n var id = $scope.$id;\n var context = {\n spies: []\n };\n\n context.handler = createScrollHandler(context);\n contexts[id] = context;\n\n $scope.$on('$destroy', function () {\n destroyContext($scope);\n });\n\n return id;\n };\n\n var destroyContext = function ($scope) {\n var id = $scope.$id;\n var context = contexts[id], container = context.container;\n if (context.intervalPromise) {\n $interval.cancel(context.intervalPromise);\n }\n if (container) {\n container.off('scroll', context.handler);\n }\n delete contexts[id];\n };\n\n var defaultContextId = createContext($rootScope);\n\n var getContextForScope = function (scope) {\n if (contexts[scope.$id]) {\n return contexts[scope.$id];\n }\n if (scope.$parent) {\n return getContextForScope(scope.$parent);\n }\n return contexts[defaultContextId];\n };\n\n var getContextForSpy = function (spy) {\n var context, contextId, scope = spy.$scope;\n if (scope) {\n return getContextForScope(scope);\n }\n //No scope, most likely destroyed\n for (contextId in contexts) {\n context = contexts[contextId];\n if (context.spies.indexOf(spy) !== -1) {\n return context;\n }\n }\n };\n\n var isElementInDocument = function (element) {\n while (element.parentNode) {\n element = element.parentNode;\n if (element === document) {\n return true;\n }\n }\n return false;\n };\n\n var addSpy = function (spy) {\n var context = getContextForSpy(spy);\n if (!context) return;\n context.spies.push(spy);\n if (!context.container || !isElementInDocument(context.container)) {\n if (context.container) {\n context.container.off('scroll', context.handler);\n }\n context.container = scrollContainerAPI.getContainer(spy.$scope);\n if (duScrollSpyRefreshInterval && !context.intervalPromise) {\n context.intervalPromise = $interval(context.handler, duScrollSpyRefreshInterval, 0, false);\n }\n context.container.on('scroll', context.handler).triggerHandler('scroll');\n }\n };\n\n var removeSpy = function (spy) {\n var context = getContextForSpy(spy);\n if (spy === context.currentlyActive) {\n $rootScope.$broadcast('duScrollspy:becameInactive', context.currentlyActive.$element);\n context.currentlyActive = null;\n }\n var i = context.spies.indexOf(spy);\n if (i !== -1) {\n context.spies.splice(i, 1);\n }\n spy.$element = null;\n };\n\n return {\n addSpy: addSpy,\n removeSpy: removeSpy,\n createContext: createContext,\n destroyContext: destroyContext,\n getContextForScope: getContextForScope\n };\n }]);\n\n\nangular.module('duScroll.scrollContainerAPI', [])\n .factory('scrollContainerAPI', [\"$document\", function ($document) {\n 'use strict';\n\n var containers = {};\n\n var setContainer = function (scope, element) {\n var id = scope.$id;\n containers[id] = element;\n return id;\n };\n\n var getContainerId = function (scope) {\n if (containers[scope.$id]) {\n return scope.$id;\n }\n if (scope.$parent) {\n return getContainerId(scope.$parent);\n }\n\n };\n\n var getContainer = function (scope) {\n var id = getContainerId(scope);\n return id ? containers[id] : $document;\n };\n\n var removeContainer = function (scope) {\n var id = getContainerId(scope);\n if (id) {\n delete containers[id];\n }\n };\n\n return {\n getContainerId: getContainerId,\n getContainer: getContainer,\n setContainer: setContainer,\n removeContainer: removeContainer\n };\n }]);\n\n\nangular.module('duScroll.smoothScroll', ['duScroll.scrollHelpers', 'duScroll.scrollContainerAPI'])\n .directive('duSmoothScroll', [\"duScrollDuration\", \"duScrollOffset\", \"scrollContainerAPI\", function (duScrollDuration, duScrollOffset, scrollContainerAPI) {\n 'use strict';\n\n return {\n link: function ($scope, $element, $attr) {\n $element.on('click', function (e) {\n if ((!$attr.href || $attr.href.indexOf('#') === -1) && $attr.duSmoothScroll === '') return;\n\n var id = $attr.href ? $attr.href.replace(/.*(?=#[^\\s]+$)/, '').substring(1) : $attr.duSmoothScroll;\n\n var target = document.getElementById(id) || document.getElementsByName(id)[0];\n if (!target || !target.getBoundingClientRect) return;\n\n if (e.stopPropagation) e.stopPropagation();\n if (e.preventDefault) e.preventDefault();\n\n var offset = $attr.offset ? parseInt($attr.offset, 10) : duScrollOffset;\n var duration = $attr.duration ? parseInt($attr.duration, 10) : duScrollDuration;\n var container = scrollContainerAPI.getContainer($scope);\n\n container.duScrollToElement(\n angular.element(target),\n isNaN(offset) ? 0 : offset,\n isNaN(duration) ? 0 : duration\n );\n });\n }\n };\n }]);\n\n\nangular.module('duScroll.spyContext', ['duScroll.spyAPI'])\n .directive('duSpyContext', [\"spyAPI\", function (spyAPI) {\n 'use strict';\n\n return {\n restrict: 'A',\n scope: true,\n compile: function compile(tElement, tAttrs, transclude) {\n return {\n pre: function preLink($scope, iElement, iAttrs, controller) {\n spyAPI.createContext($scope);\n }\n };\n }\n };\n }]);\n\n\nangular.module('duScroll.scrollContainer', ['duScroll.scrollContainerAPI'])\n .directive('duScrollContainer', [\"scrollContainerAPI\", function (scrollContainerAPI) {\n 'use strict';\n\n return {\n restrict: 'A',\n scope: true,\n compile: function compile(tElement, tAttrs, transclude) {\n return {\n pre: function preLink($scope, iElement, iAttrs, controller) {\n iAttrs.$observe('duScrollContainer', function (element) {\n if (angular.isString(element)) {\n element = document.getElementById(element);\n }\n\n element = (angular.isElement(element) ? angular.element(element) : iElement);\n scrollContainerAPI.setContainer($scope, element);\n $scope.$on('$destroy', function () {\n scrollContainerAPI.removeContainer($scope);\n });\n });\n }\n };\n }\n };\n }]);\n\n\nangular.module('duScroll.scrollspy', ['duScroll.spyAPI'])\n .directive('duScrollspy', [\"spyAPI\", \"duScrollOffset\", \"$timeout\", \"$rootScope\", function (spyAPI, duScrollOffset, $timeout, $rootScope) {\n 'use strict';\n\n var Spy = function (targetElementOrId, $scope, $element, offset) {\n if (angular.isElement(targetElementOrId)) {\n this.target = targetElementOrId;\n } else if (angular.isString(targetElementOrId)) {\n this.targetId = targetElementOrId;\n }\n this.$scope = $scope;\n this.$element = $element;\n this.offset = offset;\n };\n\n Spy.prototype.getTargetElement = function () {\n if (!this.target && this.targetId) {\n this.target = document.getElementById(this.targetId) || document.getElementsByName(this.targetId)[0];\n }\n return this.target;\n };\n\n Spy.prototype.getTargetPosition = function () {\n var target = this.getTargetElement();\n if (target) {\n return target.getBoundingClientRect();\n }\n };\n\n Spy.prototype.flushTargetCache = function () {\n if (this.targetId) {\n this.target = undefined;\n }\n };\n\n return {\n link: function ($scope, $element, $attr) {\n var href = $attr.ngHref || $attr.href;\n var targetId;\n\n if (href && href.indexOf('#') !== -1) {\n targetId = href.replace(/.*(?=#[^\\s]+$)/, '').substring(1);\n } else if ($attr.duScrollspy) {\n targetId = $attr.duScrollspy;\n } else if ($attr.duSmoothScroll) {\n targetId = $attr.duSmoothScroll;\n }\n if (!targetId) return;\n\n // Run this in the next execution loop so that the scroll context has a chance\n // to initialize\n var timeoutPromise = $timeout(function () {\n var spy = new Spy(targetId, $scope, $element, -($attr.offset ? parseInt($attr.offset, 10) : duScrollOffset));\n spyAPI.addSpy(spy);\n\n $scope.$on('$locationChangeSuccess', spy.flushTargetCache.bind(spy));\n var deregisterOnStateChange = $rootScope.$on('$stateChangeSuccess', spy.flushTargetCache.bind(spy));\n $scope.$on('$destroy', function () {\n spyAPI.removeSpy(spy);\n deregisterOnStateChange();\n });\n }, 0, false);\n $scope.$on('$destroy', function () {\n $timeout.cancel(timeoutPromise);\n });\n }\n };\n }]);\n"]} \ No newline at end of file +{"version":3,"sources":["nzbhydra.js","directives/tasks.js","directives/tab-or-chart.js","directives/selection-button.js","directives/search-result.js","directives/save-or-send-torrent.js","directives/on-finish-render.js","directives/multiselect-dropdown.js","directives/keep-focus.js","directives/indexer-state-switch.js","directives/indexer-selection-button.js","directives/indexer-input.js","directives/hydra-updates.js","directives/hydra-news.js","directives/hydra-log.js","directives/hydra-checks-footer.js","directives/footer.js","directives/focus-on.js","directives/downloaderStatusFooter.js","directives/download-nzbzip-button.js","directives/download-nzbs-button.js","directives/dataTableDirectives.js","directives/connection-test.js","directives/click-outside.js","directives/cfg-form-entry.js","directives/backup.js","directives/addable-nzbs.js","directives/addable-nzb.js","config/formly-indexers.js","config/formly-downloaders.js","config/formly-config.js","config/config-service.js","config/config-fields-service.js","config/config-controller.js","update-service.js","system-controller.js","stats-service.js","stats-controller.js","search-service.js","search-results-controller.js","search-history-service.js","search-history-controller.js","search-controller.js","restart-service.js","nzbhydra-control-service.js","nzb-download-service.js","notifications-service.js","notification-history-controller.js","modal.js","modal-service.js","migration-service.js","login-controller.js","indexer-statuses-controller.js","index-controller.js","hydra-auth-service.js","header-controller.js","generic-storage-service.js","generic-error-handler.js","filters.js","file-selection-service.js","file-download-service.js","downloader-categories-service.js","download-history-controller.js","debug-service.js","categories-service.js","backup-service.js","angular-scroll.js"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/5BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/CA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClRA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrIA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7DA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC1FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/LA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChWA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC1FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACdA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnHA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxXA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC5HA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC9EA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3BA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7xCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7ZA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3lBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACvQA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtzEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnVA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChOA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC9VA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC7GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/jCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACpPA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzeA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC5FA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC3HA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACxCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChLA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACnBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AC/GA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACTA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClGA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACvFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACtCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AChHA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACfA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzFA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACjKA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AClDA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACrCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;ACzBA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"nzbhydra.js","sourcesContent":["// For caching HTML templates, see http://paulsalaets.com/pre-caching-angular-templates-with-gulp\nangular.module('templates', []);\n\nvar nzbhydraapp = angular.module('nzbhydraApp', ['angular-loading-bar', 'cgBusy', 'ui.bootstrap', 'ipCookie', 'angular-growl',\n 'angular.filter', 'filters', 'ui.router', 'blockUI', 'mgcrea.ngStrap', 'angularUtils.directives.dirPagination',\n 'nvd3', 'formly', 'formlyBootstrap', 'frapontillo.bootstrap-switch', 'ui.select', 'ngSanitize', 'checklist-model',\n 'ngAria', 'ngMessages', 'ui.router.title', 'LocalStorageModule', 'angular.filter', 'ngFileUpload', 'ngCookies', 'angular.chips',\n 'templates', 'base64', 'duScroll', 'colorpicker.module']);\n\nnzbhydraapp.config(['$compileProvider', function ($compileProvider) {\n $compileProvider.debugInfoEnabled(true);\n}]);\n\nnzbhydraapp.config(['$animateProvider', function ($animateProvider) {\n}]);\n\nangular.module('nzbhydraApp').config([\"$stateProvider\", \"$urlRouterProvider\", \"$locationProvider\", \"blockUIConfig\", \"$urlMatcherFactoryProvider\", \"localStorageServiceProvider\", \"bootstrapped\", function ($stateProvider, $urlRouterProvider, $locationProvider, blockUIConfig, $urlMatcherFactoryProvider, localStorageServiceProvider, bootstrapped) {\n blockUIConfig.autoBlock = false;\n blockUIConfig.resetOnException = false;\n blockUIConfig.autoInjectBodyBlock = false;\n $urlMatcherFactoryProvider.strictMode(false);\n\n $urlRouterProvider.otherwise(\"/\");\n\n $stateProvider\n .state('root', {\n url: '',\n abstract: true,\n resolve: {\n //loginRequired: loginRequired\n },\n views: {\n 'header': {\n templateUrl: 'static/html/states/header.html',\n controller: 'HeaderController',\n resolve: {\n bootstrapped: function () {\n return bootstrapped;\n }\n }\n }\n }\n })\n .state(\"root.config\", {\n url: \"/config\",\n views: {},\n abstract: true\n })\n .state(\"root.config.main\", {\n url: \"/main\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n controllerAs: 'ctrl',\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 0;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Main)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.auth\", {\n url: \"/auth\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 1;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Auth)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.searching\", {\n url: \"/searching\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 2;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Searching)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.categories\", {\n url: \"/categories\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 3;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Categories)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.downloading\", {\n url: \"/downloading\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 4;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Downloading)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.indexers\", {\n url: \"/indexers\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 5;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Indexers)\"\n }]\n }\n }\n }\n })\n .state(\"root.config.notifications\", {\n url: \"/notifications\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/config.html\",\n controller: \"ConfigController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n config: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.get();\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 6;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Config (Notifications)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats\", {\n url: \"/stats\",\n abstract: true,\n views: {\n 'container@': {\n templateUrl: \"static/html/states/stats.html\",\n controller: [\"$scope\", \"$state\", function ($scope, $state) {\n $scope.$state = $state;\n $scope.bootstrapped = bootstrapped;\n }],\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats\"\n }]\n }\n\n }\n }\n })\n .state(\"root.stats.main\", {\n url: \"/stats\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/main-stats.html\",\n controller: \"StatsController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.indexers\", {\n url: \"/indexers\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/indexer-statuses.html\",\n controller: IndexerStatusesController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n statuses: [\"$http\", function ($http) {\n return $http.get(\"internalapi/indexerstatuses\").then(function (response) {\n return response;\n });\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Indexers)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.searches\", {\n url: \"/searches\",\n views: {\n 'stats@root.stats': {\n templateUrl: \"static/html/states/search-history.html\",\n controller: SearchHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n history: ['loginRequired', 'SearchHistoryService', function (loginRequired, SearchHistoryService) {\n return SearchHistoryService.getSearchHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Searches)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.downloads\", {\n url: \"/downloads\",\n views: {\n 'stats@root.stats': {\n templateUrl: 'static/html/states/download-history.html',\n controller: DownloadHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n downloads: [\"StatsService\", function (StatsService) {\n return StatsService.getDownloadHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Downloads)\"\n }]\n }\n }\n }\n })\n .state(\"root.stats.notifications\", {\n url: \"/notifications\",\n views: {\n 'stats@root.stats': {\n templateUrl: 'static/html/states/notification-history.html',\n controller: NotificationHistoryController,\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"stats\")\n }],\n preloadData: [\"StatsService\", function (StatsService) {\n return StatsService.getNotificationHistory();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Stats (Notifications)\"\n }]\n }\n }\n }\n })\n .state(\"root.system\", {\n url: \"/system\",\n views: {},\n abstract: true\n })\n .state(\"root.system.control\", {\n url: \"/control\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 0;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System\"\n }]\n }\n }\n }\n })\n .state(\"root.system.updates\", {\n url: \"/updates\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 1;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Updates)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.log\", {\n url: \"/log\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 2;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Log)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.tasks\", {\n url: \"/tasks\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 3;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Tasks)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.backup\", {\n url: \"/backup\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 4;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Backup)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.bugreport\", {\n url: \"/bugreport\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 5;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (Bug report)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.news\", {\n url: \"/news\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n activeTab: [function () {\n return 6;\n }],\n simpleInfos: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (News)\"\n }]\n }\n }\n }\n })\n .state(\"root.system.about\", {\n url: \"/about\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/system.html\",\n controller: \"SystemController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"admin\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n simpleInfos: ['$http', 'RequestsErrorHandler', function ($http, RequestsErrorHandler) {\n return RequestsErrorHandler.specificallyHandled(function () {\n return $http.get(\"internalapi/updates/simpleInfos\").then(\n function (response) {\n return response.data;\n }\n );\n });\n }],\n activeTab: [function () {\n return 7;\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"System (About)\"\n }]\n }\n }\n }\n })\n\n .state(\"root.search\", {\n url: \"/?category&query&imdbId&tvdbId&title&season&episode&minsize&maxsize&minage&maxage&offsets&tvrageId&mode&tmdbId&indexers&tvmazeId&sortby&sortdirection\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/search.html\",\n controller: \"SearchController\",\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"search\")\n }],\n safeConfig: ['loginRequired', 'ConfigService', function (loginRequired, ConfigService) {\n return ConfigService.getSafe();\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Search\";\n }]\n }\n }\n }\n })\n .state(\"root.search.results\", {\n views: {\n 'results@root.search': {\n templateUrl: \"static/html/states/search-results.html\",\n controller: \"SearchResultsController\",\n controllerAs: \"srController\",\n options: {\n inherit: true\n },\n params: {\n modalInstance: null\n },\n resolve: {\n loginRequired: ['$q', '$timeout', '$state', 'HydraAuthService', function ($q, $timeout, $state, HydraAuthService) {\n return loginRequired($q, $timeout, $state, HydraAuthService, \"search\")\n }],\n $title: [\"$stateParams\", function ($stateParams) {\n var title = \"Search results\";\n var details;\n if ($stateParams.title) {\n details = $stateParams.title;\n } else if ($stateParams.query) {\n details = $stateParams.query;\n }\n if (details) {\n title += \" (\" + details + \")\";\n }\n return title;\n }]\n }\n }\n }\n })\n .state(\"root.login\", {\n url: \"/login\",\n views: {\n 'container@': {\n templateUrl: \"static/html/states/login.html\",\n controller: \"LoginController\",\n resolve: {\n loginRequired: function () {\n return null;\n },\n $title: [\"$stateParams\", function ($stateParams) {\n return \"Login\"\n }]\n }\n }\n }\n })\n ;\n\n\n $locationProvider.html5Mode(true);\n\n\n function loginRequired($q, $timeout, $state, HydraAuthService, type) {\n var deferred = $q.defer();\n var userInfos = HydraAuthService.getUserInfos();\n var allowed = false;\n if (type === \"search\") {\n allowed = !userInfos.searchRestricted || userInfos.maySeeSearch;\n } else if (type === \"stats\") {\n allowed = !userInfos.statsRestricted || userInfos.maySeeStats;\n } else if (type === \"admin\") {\n allowed = !userInfos.adminRestricted || userInfos.maySeeAdmin;\n } else {\n allowed = true;\n }\n if (allowed || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n\n //Because I don't know for what state the login is required / asked I have a function for each\n\n function loginRequiredSearch($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.searchRestricted || userInfos.maySeeSearch || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n function loginRequiredStats($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.statsRestricted || userInfos.maySeeStats || userInfos.authType !== \"FORM\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n function loginRequiredAdmin($q, $timeout, $state, HydraAuthService) {\n var deferred = $q.defer();\n\n var userInfos = HydraAuthService.getUserInfos();\n if (!userInfos.statsRestricted || userInfos.maySeeAdmin || userInfos.authType != \"form\") {\n deferred.resolve();\n } else {\n $timeout(function () {\n // This code runs after the authentication promise has been rejected.\n // Go to the log-in page\n $state.go(\"root.login\");\n })\n }\n return deferred.promise;\n }\n\n localStorageServiceProvider\n .setPrefix('nzbhydra');\n localStorageServiceProvider\n .setNotify(true, false);\n}]);\n\n\nnzbhydraapp.config([\"paginationTemplateProvider\", function (paginationTemplateProvider) {\n paginationTemplateProvider.setPath('static/html/dirPagination.tpl.html');\n}]);\n\nnzbhydraapp.config(['cfpLoadingBarProvider', function (cfpLoadingBarProvider) {\n cfpLoadingBarProvider.latencyThreshold = 100;\n}]);\n\nnzbhydraapp.config(['growlProvider', function (growlProvider) {\n growlProvider.globalTimeToLive(5000);\n growlProvider.globalPosition('bottom-right');\n}]);\n\nnzbhydraapp.directive('ngEnter', function () {\n return function (scope, element, attr) {\n element.bind(\"keydown keypress\", function (event) {\n if (event.which === 13) {\n scope.$apply(function () {\n scope.$evalAsync(attr.ngEnter);\n });\n\n event.preventDefault();\n }\n });\n };\n});\n\nnzbhydraapp.filter('nzblink', function () {\n return function (resultItem) {\n var uri = new URI(\"internalapi/getnzb/user/\" + resultItem.searchResultId);\n return uri.toString();\n }\n});\n\nnzbhydraapp.factory('focus', [\"$rootScope\", \"$timeout\", function ($rootScope, $timeout) {\n return function (name) {\n $timeout(function () {\n $rootScope.$broadcast('focusOn', name);\n });\n }\n}]);\n\nnzbhydraapp.run([\"$rootScope\", function ($rootScope) {\n $rootScope.$on('$stateChangeSuccess',\n function (event, toState, toParams, fromState, fromParams) {\n try {\n $rootScope.title = toState.views[Object.keys(toState.views)[0]].resolve.$title[1](toParams);\n } catch (e) {\n\n }\n\n });\n}]);\n\nnzbhydraapp.filter('dereferer', [\"ConfigService\", function (ConfigService) {\n return function (url) {\n if (ConfigService.getSafe().dereferer) {\n return ConfigService.getSafe().dereferer\n .replace(\"$s\", escape(url))\n .replace(\"$us\", url);\n }\n return url;\n }\n}]);\n\nnzbhydraapp.filter('derefererExtracting', [\"ConfigService\", function (ConfigService) {\n return function (aString) {\n if (!ConfigService.getSafe().dereferer || !aString) {\n return aString\n }\n var matches = aString.match(/(http|ftp|https):\\/\\/([\\w_-]+(?:(?:\\.[\\w_-]+)+))([\\w.,@?^=%&:/~+#-]*[\\w@?^=%&/~+#-])?/);\n if (matches === null) {\n return aString;\n }\n\n aString = aString\n .replace(matches[0], ConfigService.getSafe().dereferer.replace(\"$s\", escape(matches[0])))\n .replace(matches[0], ConfigService.getSafe().dereferer.replace(\"$us\", matches[0]))\n ;\n\n return aString;\n }\n}]);\n\nnzbhydraapp.filter('binsearch', [\"ConfigService\", function (ConfigService) {\n return function (url) {\n return \"http://binsearch.info/?q=\" + encodeURIComponent(url) + \"&max=100&adv_age=3000&server=\";\n }\n}]);\n\nnzbhydraapp.config([\"$provide\", function ($provide) {\n $provide.decorator(\"$exceptionHandler\", ['$delegate', '$injector', function ($delegate, $injector) {\n return function (exception, cause) {\n $delegate(exception, cause);\n try {\n\n if (angular.isDefined(exception.stack)) {\n var stack = exception.stack.split('\\n').map(function (line) {\n return line.trim();\n });\n stack = stack.join(\"\\n\");\n //$injector.get(\"$http\").put(\"internalapi/logerror\", {error: stack, cause: angular.isDefined(cause) ? cause.toString() : \"No known cause\"});\n }\n } catch (e) {\n console.error(\"Unable to log JS exception to server\", e);\n }\n };\n }]);\n}]);\n\n_.mixin({\n isNullOrEmpty: function (string) {\n return (_.isUndefined(string) || _.isNull(string) || (_.isString(string) && string.length === 0))\n }\n});\n\nnzbhydraapp.factory('sessionInjector', [\"$injector\", function ($injector) {\n var sessionInjector = {\n response: function (response) {\n if (response.headers(\"Hydra-MaySeeAdmin\") != null) {\n $injector.get(\"HydraAuthService\").setLoggedInByBasic(response.headers(\"Hydra-MaySeeStats\") == \"True\", response.headers(\"Hydra-MaySeeAdmin\") == \"True\", response.headers(\"Hydra-Username\"))\n }\n\n return response;\n }\n };\n return sessionInjector;\n}]);\n\nnzbhydraapp.config(['$httpProvider', function ($httpProvider) {\n $httpProvider.interceptors.push('sessionInjector');\n $httpProvider.defaults.xsrfCookieName = 'HYDRA-XSRF-TOKEN';\n}]);\n\nnzbhydraapp.directive('autoFocus', [\"$timeout\", function ($timeout) {\n return {\n restrict: 'AC',\n link: function (_scope, _element, attrs) {\n if (attrs.noFocus) {\n return;\n }\n $timeout(function () {\n _element[0].focus();\n }, 0);\n }\n };\n}]);\n\nnzbhydraapp.factory('responseObserver', [\"$q\", \"$window\", \"growl\", function responseObserver($q, $window, growl) {\n return {\n 'responseError': function (errorResponse) {\n switch (errorResponse.status) {\n case 403:\n growl.info(\"You are not allowed to visit that section.\");\n break;\n }\n if (angular.isDefined(errorResponse.config)) {\n errorResponse.config.alreadyHandled = true;\n }\n return $q.reject(errorResponse);\n }\n };\n}]);\n\nnzbhydraapp.config([\"$httpProvider\", function ($httpProvider) {\n $httpProvider.interceptors.push('responseObserver');\n}]);\n\n\nnzbhydraapp.factory('focus', [\"$timeout\", \"$window\", function ($timeout, $window) {\n return function (id) {\n // timeout makes sure that it is invoked after any other event has been triggered.\n // e.g. click events that need to run before the focus or\n // inputs elements that are in a disabled state but are enabled when those events\n // are triggered.\n $timeout(function () {\n var element = $window.document.getElementById(id);\n if (element)\n element.focus();\n });\n };\n}]);\n\nnzbhydraapp.directive('eventFocus', [\"focus\", function (focus) {\n return function (scope, elem, attr) {\n elem.on(attr.eventFocus, function () {\n focus(attr.eventFocusId);\n });\n\n // Removes bound events in the element itself\n // when the scope is destroyed\n scope.$on('$destroy', function () {\n elem.off(attr.eventFocus);\n });\n };\n}]);\n\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('hydraTasks', hydraTasks);\n\nfunction hydraTasks() {\n controller.$inject = [\"$scope\", \"$http\"];\n return {\n templateUrl: 'static/html/directives/tasks.html',\n controller: controller\n };\n\n function controller($scope, $http) {\n\n $http.get(\"internalapi/tasks\").then(function (response) {\n $scope.tasks = response.data;\n });\n\n $scope.runTask = function (taskName) {\n $http.put(\"internalapi/tasks/\" + taskName).then(function (response) {\n $scope.tasks = response.data;\n });\n }\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('tabOrChart', tabOrChart);\r\n\r\nfunction tabOrChart() {\r\n return {\r\n templateUrl: 'static/html/directives/tab-or-chart.html',\r\n transclude: {\r\n \"chartSlot\": \"chart\",\r\n \"tableSlot\": \"table\"\r\n },\r\n restrict: 'E',\r\n replace: true,\r\n scope: {\r\n display: \"@\"\r\n }\r\n\r\n };\r\n\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('selectionButton', selectionButton);\r\n\r\nfunction selectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/selection-button.html',\r\n scope: {\r\n selected: \"=\",\r\n selectable: \"=\",\r\n invertSelection: \"<\",\r\n selectAll: \"<\",\r\n deselectAll: \"<\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n if (angular.isUndefined($scope.btn)) {\r\n $scope.btn = \"default\"; //Will form class \"btn-default\"\r\n }\r\n\r\n if (angular.isUndefined($scope.invertSelection)) {\r\n $scope.invertSelection = function () {\r\n $scope.selected = _.difference($scope.selectable, $scope.selected);\r\n };\r\n }\r\n\r\n if (angular.isUndefined($scope.selectAll)) {\r\n $scope.selectAll = function () {\r\n $scope.selected.push.apply($scope.selected, $scope.selectable);\r\n };\r\n }\r\n\r\n if (angular.isUndefined($scope.deselectAll)) {\r\n $scope.deselectAll = function () {\r\n $scope.selected.splice(0, $scope.selected.length);\r\n };\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\n","\nNfoModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"nfo\"];angular\n .module('nzbhydraApp')\n .directive('searchResult', searchResult);\n\nfunction searchResult() {\n controller.$inject = [\"$scope\", \"$element\", \"$http\", \"growl\", \"$attrs\", \"$uibModal\", \"$window\", \"DebugService\", \"localStorageService\", \"HydraAuthService\", \"ConfigService\"];\n return {\n templateUrl: 'static/html/directives/search-result.html',\n require: '^result',\n replace: false,\n scope: {\n result: \"<\",\n searchResultsControllerShared: \"<\"\n },\n controller: controller\n };\n\n\n function handleDisplay($scope, localStorageService, ConfigService) {\n //Display state / expansion\n $scope.foo.duplicatesDisplayed = localStorageService.get(\"duplicatesDisplayed\") !== null ? localStorageService.get(\"duplicatesDisplayed\") : false;\n $scope.foo.showCovers = localStorageService.get(\"showCovers\") !== null ? localStorageService.get(\"showCovers\") : true;\n $scope.foo.alwaysShowTitles = localStorageService.get(\"alwaysShowTitles\") !== null ? localStorageService.get(\"alwaysShowTitles\") : true;\n $scope.duplicatesExpanded = false;\n $scope.titlesExpanded = $scope.searchResultsControllerShared.expandGroupsByDefault;\n $scope.coverSize = ConfigService.getSafe().searching.coverSize;\n\n function calculateDisplayState() {\n $scope.resultDisplayed = ($scope.result.titleGroupIndex === 0 || $scope.titlesExpanded) && ($scope.duplicatesExpanded || $scope.result.duplicateGroupIndex === 0);\n }\n\n calculateDisplayState();\n\n $scope.toggleTitleExpansion = function () {\n $scope.titlesExpanded = !$scope.titlesExpanded;\n $scope.$emit(\"toggleTitleExpansionUp\", $scope.titlesExpanded, $scope.result.titleGroupIndicator);\n };\n\n $scope.toggleDuplicateExpansion = function () {\n $scope.duplicatesExpanded = !$scope.duplicatesExpanded;\n $scope.$emit(\"toggleDuplicateExpansionUp\", $scope.duplicatesExpanded, $scope.result.hash);\n };\n\n $scope.$on(\"toggleTitleExpansionDown\", function ($event, value, titleGroupIndicator) {\n if ($scope.result.titleGroupIndicator === titleGroupIndicator) {\n $scope.titlesExpanded = value;\n calculateDisplayState();\n }\n });\n\n $scope.$on(\"toggleDuplicateExpansionDown\", function ($event, value, hash) {\n if ($scope.result.hash === hash) {\n $scope.duplicatesExpanded = value;\n calculateDisplayState();\n }\n });\n\n $scope.$on(\"toggleShowCovers\", function ($event, value) {\n $scope.foo.showCovers = value;\n });\n\n $scope.$on(\"toggleAlwaysShowTitles\", function ($event, value) {\n $scope.foo.alwaysShowTitles = value;\n console.log(\"alwaysShowTitles: \" + alwaysShowTitles);\n });\n\n $scope.$on(\"duplicatesDisplayed\", function ($event, value) {\n $scope.foo.duplicatesDisplayed = value;\n if (!value) {\n //Collapse duplicate groups they shouldn't be displayed\n $scope.duplicatesExpanded = false;\n }\n calculateDisplayState();\n });\n\n $scope.$on(\"calculateDisplayState\", function () {\n calculateDisplayState();\n });\n }\n\n function handleSelection($scope, $element) {\n $scope.foo.selected = false;\n\n function sendSelectionEvent(isSelected) {\n $scope.$emit(\"selectionUp\", $scope.result, isSelected);\n }\n\n $scope.clickCheckbox = function (event, result) {\n var isSelected = event.currentTarget.checked;\n sendSelectionEvent(isSelected);\n $scope.$emit(\"checkboxClicked\", event, isSelected, event.currentTarget);\n };\n\n function isBetween(num, betweena, betweenb) {\n return (betweena <= num && num <= betweenb) || (betweena >= num && num >= betweenb);\n }\n\n $scope.$on(\"shiftClick\", function (event, newValue, previousClickTargetElement, newClickTargetElement) {\n //Parent needs to be the td, between checkbox and td are two divs\n var fromYlocation = $(previousClickTargetElement).parent().parent().parent().prop(\"offsetTop\");\n var newYlocation = $(newClickTargetElement).parent().parent().parent().prop(\"offsetTop\");\n var elementYlocation = $($element).prop(\"offsetTop\");\n if (!$scope.resultDisplayed) {\n return;\n }\n\n if (isBetween(elementYlocation, fromYlocation, newYlocation)) {\n sendSelectionEvent(newValue);\n $scope.foo.selected = newValue === 1;\n }\n });\n\n $scope.$on(\"invertSelection\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = !$scope.foo.selected;\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"deselectAll\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = false;\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"selectAll\", function () {\n if (!$scope.resultDisplayed) {\n return;\n }\n $scope.foo.selected = true;\n\n sendSelectionEvent($scope.foo.selected);\n });\n\n $scope.$on(\"toggleSelection\", function ($event, result, value) {\n if (!$scope.resultDisplayed || result !== $scope.result) {\n return;\n }\n $scope.foo.selected = value;\n });\n }\n\n function handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService) {\n $scope.showDetailsDl = HydraAuthService.getUserInfos().maySeeDetailsDl;\n\n $scope.showNfo = showNfo;\n\n function showNfo(resultItem) {\n if (resultItem.has_nfo === 0) {\n return;\n }\n var uri = new URI(\"internalapi/nfo/\" + resultItem.searchResultId);\n return $http.get(uri.toString()).then(function (response) {\n if (response.data.successful) {\n if (response.data.hasNfo) {\n $scope.openModal(\"lg\", response.data.content)\n } else {\n growl.info(\"No NFO available\");\n }\n } else {\n growl.error(response.data.content);\n }\n });\n }\n\n $scope.openModal = openModal;\n\n function openModal(size, nfo) {\n var modalInstance = $uibModal.open({\n template: '
                  ',\n controller: NfoModalInstanceCtrl,\n size: size,\n resolve: {\n nfo: function () {\n return nfo;\n }\n }\n });\n\n modalInstance.result.then();\n }\n\n $scope.getNfoTooltip = function () {\n if ($scope.result.hasNfo === \"YES\") {\n return \"Show NFO\"\n } else if ($scope.result.hasNfo === \"MAYBE\") {\n return \"Try to load NFO (may not be available)\";\n } else {\n return \"No NFO available\";\n }\n };\n }\n\n function handleNzbDownload($scope, $window) {\n $scope.downloadNzb = downloadNzb;\n\n function downloadNzb(resultItem) {\n //href = \"{{ result.link }}\"\n $window.location.href = resultItem.link;\n }\n }\n\n\n function controller($scope, $element, $http, growl, $attrs, $uibModal, $window, DebugService, localStorageService, HydraAuthService, ConfigService) {\n $scope.foo = {};\n handleDisplay($scope, localStorageService, ConfigService);\n handleSelection($scope, $element);\n handleNfoDisplay($scope, $http, growl, $uibModal, HydraAuthService);\n handleNzbDownload($scope, $window);\n\n $scope.kify = function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n };\n };\n\n\n $scope.showCover = function (url) {\n console.log(\"Show \" + url);\n $uibModal.open({\n template: '
                  \\n' +\n ' \\n' +\n '
                  ',\n controller: [\"$scope\", \"url\", function ($scope, url) {\n $scope.url = url;\n }],\n resolve: {\n url: function () {\n return url;\n }\n },\n size: \"md\",\n keyboard: true,\n windowTopClass: 'cover-modal-dialog'\n });\n };\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NfoModalInstanceCtrl', NfoModalInstanceCtrl);\n\nfunction NfoModalInstanceCtrl($scope, $uibModalInstance, nfo) {\n\n $scope.nfo = nfo;\n\n $scope.ok = function () {\n $uibModalInstance.close($scope.selected.item);\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .filter('kify', function () {\n return function (number) {\n if (number > 1000) {\n return Math.round(number / 1000) + \"k\";\n }\n return number;\n }\n });\n","angular\r\n .module('nzbhydraApp')\r\n .directive('saveOrSendFile', saveOrSendFile);\r\n\r\nfunction saveOrSendFile() {\r\n controller.$inject = [\"$scope\", \"$http\", \"growl\", \"ConfigService\"];\r\n return {\r\n templateUrl: 'static/html/directives/save-or-send-file.html',\r\n scope: {\r\n searchResultId: \"<\",\r\n isFile: \"<\",\r\n type: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, growl, ConfigService) {\r\n $scope.cssClass = \"glyphicon-save-file\";\r\n var endpoint;\r\n if ($scope.type === \"TORRENT\") {\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveTorrentsTo) || ConfigService.getSafe().downloading.sendMagnetLinks;\r\n $scope.tooltip = \"Save torrent to black hole or send magnet link\";\r\n endpoint = \"internalapi/saveOrSendTorrent\";\r\n } else {\r\n $scope.tooltip = \"Save NZB to black hole\";\r\n $scope.enableButton = !_.isNullOrEmpty(ConfigService.getSafe().downloading.saveNzbsTo);\r\n endpoint = \"internalapi/saveNzbToBlackhole\";\r\n }\r\n $scope.add = function () {\r\n $scope.cssClass = \"nzb-spinning\";\r\n $http.put(endpoint, $scope.searchResultId).then(function (response) {\r\n if (response.data.successful) {\r\n $scope.cssClass = \"glyphicon-ok\";\r\n } else {\r\n $scope.cssClass = \"glyphicon-remove\";\r\n growl.error(response.data.message);\r\n }\r\n });\r\n };\r\n }\r\n}\r\n","//Can be used in an ng-repeat directive to call a function when the last element was rendered\n//We use it to mark the end of sorting / filtering so we can stop blocking the UI\n\nonFinishRender.$inject = [\"$timeout\"];\nangular\n .module('nzbhydraApp')\n .directive('onFinishRender', onFinishRender);\n\nfunction onFinishRender($timeout) {\n function linkFunction(scope, element, attr) {\n\n if (scope.$last === true) {\n console.log(\"Render finished\");\n // console.timeEnd(\"Presenting\");\n // console.timeEnd(\"searchall\");\n scope.$emit(\"onFinishRender\")\n }\n }\n\n return {\n link: linkFunction\n }\n}","//Fork of https://github.com/dotansimha/angularjs-dropdown-multiselect to make it compatible with formly\nangular\n .module('nzbhydraApp')\n .directive('multiselectDropdown',\n\n dropdownMultiselectDirective\n );\n\nfunction dropdownMultiselectDirective() {\n return {\n scope: {\n selectedModel: '=',\n options: '=',\n settings: '=?',\n events: '=?'\n },\n transclude: {\n toggleDropdown: '?toggleDropdown'\n },\n templateUrl: 'static/html/directives/multiselect-dropdown.html',\n controller: [\"$scope\", \"$element\", \"$filter\", \"$document\", function dropdownMultiselectController($scope, $element, $filter, $document) {\n var $dropdownTrigger = $element.children()[0];\n\n var settings = {\n showSelectedValues: true,\n showSelectAll: true,\n showDeselectAll: true,\n noSelectedText: 'None selected'\n };\n var events = {\n onToggleItem: angular.noop\n };\n angular.extend(events, $scope.events || []);\n angular.extend(settings, $scope.settings || []);\n angular.extend($scope, {settings: settings, events: events});\n\n $scope.buttonText = \"\";\n if (settings.buttonText) {\n $scope.buttonText = settings.buttonText;\n } else {\n $scope.$watch(\"selectedModel\", function () {\n if (angular.isDefined($scope.selectedModel) && settings.showSelectedValues) {\n if ($scope.selectedModel.length === 0) {\n if ($scope.settings.noSelectedText) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = \"None selected\";\n }\n } else if ($scope.selectedModel.length === $scope.options.length) {\n $scope.buttonText = \"All selected\";\n } else {\n var selected = [];\n _.each($scope.options, function (x) {\n if ($scope.selectedModel.indexOf(x.id) > -1) {\n selected.push(x.label);\n }\n })\n $scope.buttonText = selected.join(\", \");\n }\n } else {\n if (angular.isUndefined($scope.selectedModel) || ($scope.settings.noSelectedText && $scope.selectedModel.length === 0)) {\n $scope.buttonText = $scope.settings.noSelectedText;\n } else {\n $scope.buttonText = $scope.selectedModel.length + \" / \" + $scope.options.length + \" selected\";\n }\n }\n }, true);\n }\n $scope.open = false;\n\n $scope.toggleDropdown = function () {\n $scope.open = !$scope.open;\n };\n\n $scope.toggleItem = function (option) {\n var index = $scope.selectedModel.indexOf(option.id);\n var oldValue = index > -1;\n if (oldValue) {\n $scope.selectedModel.splice(index, 1);\n } else {\n $scope.selectedModel.push(option.id);\n }\n $scope.events.onToggleItem(option, !oldValue);\n };\n\n $scope.selectAll = function () {\n $scope.selectedModel = _.pluck($scope.options, \"id\");\n };\n\n $scope.deselectAll = function () {\n $scope.selectedModel.splice(0, $scope.selectedModel.length);\n };\n\n //Close when clicked outside\n\n $document.on('click', function (e) {\n function contains(collection, target) {\n var containsTarget = false;\n collection.some(function (object) {\n if (object === target) {\n containsTarget = true;\n return true;\n }\n return false;\n });\n return containsTarget;\n }\n\n if ($scope.open) {\n var target = e.target.parentElement;\n var parentFound = false;\n\n while (angular.isDefined(target) && target !== null && !parentFound) {\n if (!!target.className.split && contains(target.className.split(' '), 'multiselect-parent') && !parentFound) {\n if (target === $dropdownTrigger) {\n parentFound = true;\n }\n }\n target = target.parentElement;\n }\n\n if (!parentFound) {\n $scope.$apply(function () {\n $scope.open = false;\n });\n }\n }\n });\n\n\n }]\n\n }\n}","angular\r\n .module('nzbhydraApp').directive(\"keepFocus\", ['$timeout', function ($timeout) {\r\n /*\r\n Intended use:\r\n \r\n */\r\n return {\r\n restrict: 'A',\r\n require: 'ngModel',\r\n link: function ($scope, $element, attrs, ngModel) {\r\n\r\n ngModel.$parsers.unshift(function (value) {\r\n $timeout(function () {\r\n $element[0].focus();\r\n });\r\n return value;\r\n });\r\n\r\n }\r\n };\r\n}]);","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerStateSwitch', indexerStateSwitch);\r\n\r\nfunction indexerStateSwitch() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-state-switch.html',\r\n scope: {\r\n indexer: \"=\",\r\n handleWidth: \"@\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.value = $scope.indexer.state === \"ENABLED\";\r\n $scope.handleWidth = $scope.handleWidth || \"130px\";\r\n var initialized = false;\r\n\r\n function calculateTextAndColor() {\r\n if ($scope.indexer.state === \"DISABLED_USER\") {\r\n $scope.offText = \"Disabled by user\";\r\n $scope.offColor = \"default\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM_TEMPORARY\") {\r\n $scope.offText = \"Temporary disabled\";\r\n $scope.offColor = \"warning\";\r\n } else if ($scope.indexer.state === \"DISABLED_SYSTEM\") {\r\n $scope.offText = \"Disabled by system\";\r\n $scope.offColor = \"danger\";\r\n }\r\n }\r\n\r\n calculateTextAndColor();\r\n\r\n $scope.onChange = function () {\r\n if (initialized) {\r\n //Skip on first call when initial value is set\r\n $scope.indexer.state = $scope.value ? \"ENABLED\" : \"DISABLED_USER\";\r\n calculateTextAndColor();\r\n }\r\n initialized = true;\r\n }\r\n }\r\n}","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .directive('indexerSelectionButton', indexerSelectionButton);\r\n\r\nfunction indexerSelectionButton() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-selection-button.html',\r\n scope: {\r\n selectedIndexers: \"=\",\r\n availableIndexers: \"=\",\r\n btn: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n\r\n $scope.anyTorrentIndexersSelectable = _.any($scope.availableIndexers,\r\n function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n }\r\n );\r\n\r\n $scope.invertSelection = function () {\r\n _.forEach($scope.availableIndexers, function (x) {\r\n var index = _.indexOf($scope.selectedIndexers, x.name);\r\n if (index === -1) {\r\n $scope.selectedIndexers.push(x.name);\r\n } else {\r\n $scope.selectedIndexers.splice(index, 1);\r\n }\r\n });\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers, _.pluck($scope.availableIndexers, \"name\"));\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selectedIndexers.splice(0, $scope.selectedIndexers.length);\r\n };\r\n\r\n function selectByPredicate(predicate) {\r\n $scope.deselectAll();\r\n $scope.selectedIndexers.push.apply($scope.selectedIndexers,\r\n _.pluck(\r\n _.filter($scope.availableIndexers,\r\n predicate\r\n ), \"name\")\r\n );\r\n }\r\n\r\n $scope.reset = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.preselect;\r\n });\r\n };\r\n\r\n $scope.selectAllUsenet = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType !== \"TORZNAB\";\r\n });\r\n };\r\n\r\n $scope.selectAllTorrent = function () {\r\n selectByPredicate(function (indexer) {\r\n return indexer.searchModuleType === \"TORZNAB\";\r\n });\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('indexerInput', indexerInput);\r\n\r\nfunction indexerInput() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/indexer-input.html',\r\n scope: {\r\n indexer: \"=\",\r\n model: \"=\",\r\n onClick: \"=\"\r\n },\r\n replace: true,\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.isFocused = false;\r\n\r\n $scope.onFocus = function () {\r\n $scope.isFocused = true;\r\n };\r\n\r\n $scope.onBlur = function () {\r\n $scope.isFocused = false;\r\n };\r\n\r\n var expiryWarning;\r\n if ($scope.indexer.vipExpirationDate != null && $scope.indexer.vipExpirationDate !== \"Lifetime\") {\r\n var expiryDate = moment($scope.indexer.vipExpirationDate, \"YYYY-MM-DD\");\r\n if (expiryDate < moment()) {\r\n console.log(\"Expiry date reached for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access expired on \" + $scope.indexer.vipExpirationDate;\r\n } else if (expiryDate.subtract(7, 'days') < moment()) {\r\n console.log(\"Expiry date near for indexer \" + $scope.indexer.name);\r\n expiryWarning = \"VIP access will expire on \" + $scope.indexer.vipExpirationDate;\r\n }\r\n }\r\n\r\n $scope.expiryWarning = expiryWarning;\r\n if ($scope.indexer.color !== null) {\r\n $scope.style = \"background-color: \" + $scope.indexer.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\r\n }\r\n }\r\n\r\n}\r\n\r\n","angular\n .module('nzbhydraApp')\n .directive('hydraupdates', hydraupdates);\n\nfunction hydraupdates() {\n controller.$inject = [\"$scope\", \"UpdateService\"];\n return {\n templateUrl: 'static/html/directives/updates.html',\n controller: controller\n };\n\n function controller($scope, UpdateService) {\n\n $scope.loadingPromise = UpdateService.getInfos().then(function (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.betaVersion = response.data.betaVersion;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.betaUpdateAvailable = response.data.betaUpdateAvailable;\n $scope.latestVersionIgnored = response.data.latestVersionIgnored;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.wrapperOutdated = response.data.wrapperOutdated;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n });\n\n UpdateService.getVersionHistory().then(function (response) {\n $scope.versionHistory = response.data;\n });\n\n\n $scope.update = function (version) {\n UpdateService.update(version);\n };\n\n $scope.showChangelog = function (version) {\n UpdateService.showChanges(version);\n };\n\n $scope.forceUpdate = function () {\n UpdateService.update($scope.latestVersion)\n };\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('hydraNews', hydraNews);\r\n\r\nfunction hydraNews() {\r\n controller.$inject = [\"$scope\", \"$http\"];\r\n return {\r\n templateUrl: \"static/html/directives/news.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http) {\r\n\r\n return $http.get(\"internalapi/news\").then(function (response) {\r\n $scope.news = response.data;\r\n });\r\n\r\n\r\n }\r\n}\r\n\r\n","\r\nLogModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"entry\"];\r\nescapeHtml.$inject = [\"$sanitize\"];angular\r\n .module('nzbhydraApp')\r\n .directive('hydralog', hydralog);\r\n\r\nfunction hydralog() {\r\n controller.$inject = [\"$scope\", \"$http\", \"$interval\", \"$uibModal\", \"$sce\", \"localStorageService\", \"growl\"];\r\n return {\r\n templateUrl: \"static/html/directives/log.html\",\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, $interval, $uibModal, $sce, localStorageService, growl) {\r\n $scope.tailInterval = null;\r\n $scope.doUpdateLog = localStorageService.get(\"doUpdateLog\") !== null ? localStorageService.get(\"doUpdateLog\") : false;\r\n $scope.doTailLog = localStorageService.get(\"doTailLog\") !== null ? localStorageService.get(\"doTailLog\") : false;\r\n\r\n $scope.active = 0;\r\n $scope.currentJsonIndex = 0;\r\n $scope.hasMoreJsonLines = true;\r\n\r\n function getLog(index) {\r\n if ($scope.active === 0) {\r\n return $http.get(\"internalapi/debuginfos/jsonlogs\", {\r\n params: {\r\n offset: index,\r\n limit: 500\r\n }\r\n }).then(function (response) {\r\n var data = response.data;\r\n $scope.jsonLogLines = angular.fromJson(data.lines);\r\n $scope.hasMoreJsonLines = data.hasMore;\r\n });\r\n } else if ($scope.active === 1) {\r\n return $http.get(\"internalapi/debuginfos/currentlogfile\").then(function (response) {\r\n var data = response.data;\r\n $scope.log = $sce.trustAsHtml(data.replace(/&/g, \"&\")\r\n .replace(//g, \">\")\r\n .replace(/\"/g, \""\")\r\n .replace(/'/g, \"'\"));\r\n }, function (data) {\r\n growl.error(data)\r\n });\r\n } else if ($scope.active === 2) {\r\n return $http.get(\"internalapi/debuginfos/logfilenames\").then(function (response) {\r\n $scope.logfilenames = response.data;\r\n });\r\n }\r\n }\r\n\r\n $scope.logPromise = getLog();\r\n\r\n $scope.select = function (index) {\r\n $scope.active = index;\r\n $scope.update();\r\n };\r\n\r\n $scope.scrollToBottom = function () {\r\n document.getElementById(\"logfile\").scrollTop = 10000000;\r\n document.getElementById(\"logfile\").scrollTop = 100001000;\r\n };\r\n\r\n $scope.update = function () {\r\n getLog($scope.currentJsonIndex);\r\n if ($scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n };\r\n\r\n $scope.getOlderFormatted = function () {\r\n getLog($scope.currentJsonIndex + 500).then(function () {\r\n $scope.currentJsonIndex += 500;\r\n });\r\n\r\n };\r\n\r\n $scope.getNewerFormatted = function () {\r\n var index = Math.max($scope.currentJsonIndex - 500, 0);\r\n getLog(index);\r\n $scope.currentJsonIndex = index;\r\n };\r\n\r\n function startUpdateLogInterval() {\r\n $scope.tailInterval = $interval(function () {\r\n if ($scope.active === 1) {\r\n $scope.update();\r\n if ($scope.doTailLog && $scope.active === 1) {\r\n $scope.scrollToBottom();\r\n }\r\n }\r\n }, 5000);\r\n }\r\n\r\n $scope.toggleUpdate = function (doUpdateLog) {\r\n $scope.doUpdateLog = doUpdateLog;\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n } else if ($scope.tailInterval !== null) {\r\n console.log(\"Cancelling\");\r\n $interval.cancel($scope.tailInterval);\r\n localStorageService.set(\"doTailLog\", false);\r\n $scope.doTailLog = false;\r\n }\r\n localStorageService.set(\"doUpdateLog\", $scope.doUpdateLog);\r\n };\r\n\r\n $scope.toggleTailLog = function () {\r\n localStorageService.set(\"doTailLog\", $scope.doTailLog);\r\n };\r\n\r\n $scope.openModal = function openModal(entry) {\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'log-entry.html',\r\n controller: LogModalInstanceCtrl,\r\n size: \"xl\",\r\n resolve: {\r\n entry: function () {\r\n return entry;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then();\r\n };\r\n\r\n $scope.$on('$destroy', function () {\r\n if ($scope.tailInterval !== null) {\r\n $interval.cancel($scope.tailInterval);\r\n }\r\n });\r\n\r\n if ($scope.doUpdateLog) {\r\n startUpdateLogInterval();\r\n }\r\n\r\n\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('LogModalInstanceCtrl', LogModalInstanceCtrl);\r\n\r\nfunction LogModalInstanceCtrl($scope, $uibModalInstance, entry) {\r\n\r\n $scope.entry = entry;\r\n\r\n $scope.ok = function () {\r\n $uibModalInstance.dismiss();\r\n };\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatTimestamp', formatTimestamp);\r\n\r\nfunction formatTimestamp() {\r\n return function (date) {\r\n //1579392000\r\n //1579374757\r\n if (date === null || date === undefined) {\r\n return null;\r\n }\r\n if (date < 1979374757) {\r\n date *= 1000;\r\n }\r\n return moment(date).local().format(\"YYYY-MM-DD HH:mm\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('escapeHtml', escapeHtml);\r\n\r\nfunction escapeHtml($sanitize) {\r\n return function (text) {\r\n return $sanitize(text);\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .filter('formatClassname', formatClassname);\r\n\r\nfunction formatClassname() {\r\n return function (fqn) {\r\n return fqn.substr(fqn.lastIndexOf(\".\") + 1);\r\n\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nNewsModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"news\"];\nWelcomeModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$state\", \"MigrationService\"];\nangular\n .module('nzbhydraApp')\n .directive('hydraChecksFooter', hydraChecksFooter);\n\nfunction hydraChecksFooter() {\n controller.$inject = [\"$scope\", \"UpdateService\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"ModalService\", \"growl\", \"NotificationService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/checks-footer.html',\n controller: controller\n };\n\n function controller($scope, UpdateService, RequestsErrorHandler, HydraAuthService, $http, $uibModal, ConfigService, GenericStorageService, ModalService, growl, NotificationService, bootstrapped) {\n $scope.updateAvailable = false;\n $scope.checked = false;\n var welcomeIsBeingShown = false;\n\n $scope.mayUpdate = HydraAuthService.getUserInfos().maySeeAdmin;\n\n $scope.$on(\"user:loggedIn\", function () {\n if (HydraAuthService.getUserInfos().maySeeAdmin && !$scope.checked) {\n retrieveUpdateInfos();\n }\n });\n\n function checkForOutOfMemoryException() {\n GenericStorageService.get(\"outOfMemoryDetected\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Out of memory error detected\", 'The log indicates that the process ran out of memory. Please increase the XMX value in the main config and restart.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"outOfMemoryDetected\", false, false);\n }\n });\n }\n\n function checkForOpenToInternet() {\n GenericStorageService.get(\"showOpenToInternetWithoutAuth\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n //headline, message, params, size, textAlign\n ModalService.open(\"Security issue - open to internet\", 'It looks like NZBHydra is exposed to the internet without any authentication enable. Please make sure it cannot be reached from outside your network or enable an authentication method.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"showOpenToInternetWithoutAuth\", false, false);\n }\n });\n }\n\n console.log(\"Checking for below Java 17.\");\n\n function checkForJavaBelow17() {\n GenericStorageService.get(\"belowJava17\", false).then(function (response) {\n if (response.data !== \"\" && response.data) {\n console.log(\"Java below 17\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Java version below 17\", 'You\\'re currently running NZBHydra2 with an older java version. A future update will require Java 17. Please install Java 17 (not higher) from here.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"belowJava17\", false, false);\n }\n });\n }\n\n console.log(\"Checking for failed backup.\");\n\n function checkForFailedBackup() {\n GenericStorageService.get(\"FAILED_BACKUP\", false).then(function (response) {\n if (response.data !== \"\" && response.data && !response.data) {\n console.log(\"Failed backup detected\");\n //headline, message, params, size, textAlign\n ModalService.open(\"Failed backup\", 'The creation of a backup file has failed. Error message: \\\"' + response.data.message + '.\"
                  For details please check the log around ' + response.data.time + '.', {\n yes: {\n text: \"OK\"\n }\n }, undefined, \"left\");\n GenericStorageService.put(\"FAILED_BACKUP\", false, null);\n }\n });\n }\n\n function checkForOutdatedWrapper() {\n $http.get(\"internalapi/updates/isDisplayWrapperOutdated\").then(function (response) {\n var data = response.data;\n if (data !== undefined && data !== null && data) {\n ModalService.open(\"Outdated wrappers detected\", 'The NZBHydra wrappers (i.e. the executables or python scripts you use to run NZBHydra) seem to be outdated. Please update them.

                  \\n' +\n ' Shut down NZBHydra, download the latest version and extract all the relevant wrapper files into your main NZBHydra folder.
                  \\n' +\n ' For Windows these files are:\\n' +\n '
                    \\n' +\n '
                  • NZBHydra2.exe
                  • \\n' +\n '
                  • NZBHydra2 Console.exe
                  • \\n' +\n '
                  \\n' +\n ' For linux these files are:\\n' +\n '
                    \\n' +\n '
                  • nzbhydra2
                  • \\n' +\n '
                  • nzbhydra2wrapper.py
                  • \\n' +\n '
                  • nzbhydra2wrapperPy3.py
                  • \\n' +\n '
                  \\n' +\n ' Make sure to overwrite all of these files that already exist - you don\\'t need to update any files that aren\\'t already present.\\n' +\n '

                  \\n' +\n ' Afterwards start NZBHydra again.', {\n yes: {\n text: \"OK\",\n onYes: function () {\n $http.put(\"internalapi/updates/setOutdatedWrapperDetectedWarningShown\")\n }\n }\n }, undefined, \"left\");\n\n }\n });\n }\n\n if ($scope.mayUpdate) {\n retrieveUpdateInfos();\n checkForOutOfMemoryException();\n checkForOutdatedWrapper();\n checkForOpenToInternet();\n checkForJavaBelow17();\n checkForFailedBackup();\n }\n\n function retrieveUpdateInfos() {\n $scope.checked = true;\n UpdateService.getInfos().then(function (response) {\n if (response) {\n $scope.currentVersion = response.data.currentVersion;\n $scope.latestVersion = response.data.latestVersion;\n $scope.latestVersionIsBeta = response.data.latestVersionIsBeta;\n $scope.updateAvailable = response.data.updateAvailable;\n $scope.changelog = response.data.changelog;\n $scope.updatedExternally = response.data.updatedExternally;\n $scope.showUpdateBannerOnUpdatedExternally = response.data.showUpdateBannerOnUpdatedExternally;\n $scope.showWhatsNewBanner = response.data.showWhatsNewBanner;\n if ($scope.updatedExternally && !$scope.showUpdateBannerOnUpdatedExternally) {\n $scope.updateAvailable = false;\n }\n $scope.automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n\n\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n $scope.$emit(\"showAutomaticUpdateFooter\", $scope.automaticUpdateToNotice);\n } else {\n $scope.$emit(\"showUpdateFooter\", false);\n }\n });\n }\n\n $scope.update = function () {\n UpdateService.update($scope.latestVersion);\n };\n\n $scope.ignore = function () {\n UpdateService.ignore($scope.latestVersion);\n $scope.updateAvailable = false;\n $scope.$emit(\"showUpdateFooter\", $scope.updateAvailable);\n };\n\n $scope.showChangelog = function () {\n UpdateService.showChanges($scope.latestVersion);\n };\n\n $scope.showChangesFromAutomaticUpdate = function () {\n UpdateService.showChangesFromAutomaticUpdate();\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n };\n\n $scope.dismissChangesFromAutomaticUpdate = function () {\n $scope.automaticUpdateToNotice = null;\n $scope.$emit(\"showAutomaticUpdateFooter\", false);\n console.log(\"Dismissing showAutomaticUpdateFooter\");\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n });\n };\n\n function checkAndShowNews() {\n RequestsErrorHandler.specificallyHandled(function () {\n if (ConfigService.getSafe().showNews) {\n $http.get(\"internalapi/news/forcurrentversion\").then(function (response) {\n var data = response.data;\n if (data && data.length > 0) {\n $uibModal.open({\n templateUrl: 'static/html/news-modal.html',\n controller: NewsModalInstanceCtrl,\n size: \"lg\",\n resolve: {\n news: function () {\n return data;\n }\n }\n });\n $http.put(\"internalapi/news/saveshown\");\n }\n });\n }\n });\n }\n\n function checkExpiredIndexers() {\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n if (indexer.vipExpirationDate != null && indexer.vipExpirationDate !== \"Lifetime\") {\n var expiryWarning;\n var expiryDate = moment(indexer.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access for indexer \" + indexer.name;\n if (expiryDate < moment()) {\n expiryWarning = messagePrefix + \" expired on \" + indexer.vipExpirationDate;\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n expiryWarning = messagePrefix + \" will expire on \" + indexer.vipExpirationDate;\n }\n if (expiryWarning) {\n console.log(expiryWarning);\n growl.warning(expiryWarning);\n }\n }\n });\n }\n\n function checkAndShowWelcome() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/welcomeshown\").then(function (response) {\n if (!response.data) {\n $http.put(\"internalapi/welcomeshown\");\n var promise = $uibModal.open({\n templateUrl: 'static/html/welcome-modal.html',\n controller: WelcomeModalInstanceCtrl,\n size: \"md\"\n });\n promise.opened.then(function () {\n welcomeIsBeingShown = true;\n });\n promise.closed.then(function () {\n welcomeIsBeingShown = false;\n });\n } else {\n if (HydraAuthService.getUserInfos().maySeeAdmin) {\n _.defer(checkAndShowNews);\n _.defer(checkExpiredIndexers);\n }\n }\n }, function () {\n console.log(\"Error while checking for welcome\")\n });\n });\n }\n\n checkAndShowWelcome();\n\n function showUnreadNotifications(unreadNotifications, stompClient) {\n if (unreadNotifications.length > ConfigService.getSafe().notificationConfig.displayNotificationsMax) {\n growl.info(unreadNotifications.length + ' notifications have piled up. Go to the notification history to view them.', {disableCountDown: true});\n for (var i = 0; i < unreadNotifications.length; i++) {\n if (unreadNotifications[i].id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, unreadNotifications[i].id);\n }\n return;\n }\n for (var j = 0; j < unreadNotifications.length; j++) {\n var notification = unreadNotifications[j];\n var body = notification.body.replace(\"\\n\", \"
                  \");\n switch (notification.messageType) {\n case \"INFO\":\n growl.info(body);\n break;\n case \"SUCCESS\":\n growl.success(body);\n break;\n case \"WARNING\":\n growl.warning(body);\n break;\n case \"FAILURE\":\n growl.danger(body);\n break;\n }\n if (notification.id === undefined) {\n console.log(\"Undefined ID found for notification \" + unreadNotifications[i]);\n continue;\n }\n stompClient.send(\"/app/markNotificationRead\", {}, notification.id);\n }\n }\n\n if (ConfigService.getSafe().notificationConfig.displayNotifications && HydraAuthService.getUserInfos().maySeeAdmin) {\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/notifications', function (message) {\n showUnreadNotifications(JSON.parse(message.body), stompClient);\n });\n });\n }\n\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('NewsModalInstanceCtrl', NewsModalInstanceCtrl);\n\nfunction NewsModalInstanceCtrl($scope, $uibModalInstance, news) {\n $scope.news = news;\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n}\n\nangular\n .module('nzbhydraApp')\n .controller('WelcomeModalInstanceCtrl', WelcomeModalInstanceCtrl);\n\nfunction WelcomeModalInstanceCtrl($scope, $uibModalInstance, $state, MigrationService) {\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.startMigration = function () {\n $uibModalInstance.dismiss();\n MigrationService.migrate();\n };\n\n $scope.goToConfig = function () {\n $uibModalInstance.dismiss();\n $state.go(\"root.config.main\");\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('footer', footer);\n\nfunction footer() {\n controller.$inject = [\"$scope\", \"$http\", \"$uibModal\", \"ConfigService\", \"GenericStorageService\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/footer.html',\n controller: controller\n };\n\n function controller($scope, $http, $uibModal, ConfigService, GenericStorageService, bootstrapped) {\n $scope.updateFooterBottom = 0;\n\n var safeConfig = bootstrapped.safeConfig;\n $scope.showDownloaderStatus = safeConfig.downloading.showDownloaderStatus && _.filter(safeConfig.downloading.downloaders, function (x) {\n return x.enabled\n }).length > 0;\n $scope.showUpdateFooter = false;\n\n $scope.$on(\"showDownloaderStatus\", function (event, doShow) {\n $scope.showDownloaderStatus = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showUpdateFooter\", function (event, doShow) {\n $scope.showUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n $scope.$on(\"showAutomaticUpdateFooter\", function (event, doShow) {\n $scope.showAutomaticUpdateFooter = doShow;\n updateFooterBottom();\n updatePaddingBottom();\n });\n\n function updateFooterBottom() {\n\n if ($scope.showDownloaderStatus) {\n if ($scope.showAutomaticUpdateFooter) {\n $scope.updateFooterBottom = 20;\n } else {\n $scope.updateFooterBottom = 38;\n }\n } else {\n $scope.updateFooterBottom = 0;\n }\n }\n\n function updatePaddingBottom() {\n var paddingBottom = 0;\n if ($scope.showDownloaderStatus) {\n paddingBottom += 30;\n }\n if ($scope.showUpdateFooter) {\n paddingBottom += 40;\n }\n $scope.paddingBottom = paddingBottom;\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-0\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-30\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-40\");\n document.getElementById(\"wrap\").classList.remove(\"padding-bottom-70\");\n var paddingBottomClass = \"padding-bottom-\" + paddingBottom;\n document.getElementById(\"wrap\").classList.add(paddingBottomClass);\n }\n\n updatePaddingBottom();\n\n updateFooterBottom();\n\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp').directive('focusOn', focusOn);\r\n\r\nfunction focusOn() {\r\n return directive;\r\n\r\n function directive(scope, elem, attr) {\r\n scope.$on('focusOn', function (e, name) {\r\n if (name === attr.focusOn) {\r\n elem[0].focus();\r\n }\r\n });\r\n }\r\n}\r\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nangular\n .module('nzbhydraApp')\n .directive('downloaderStatusFooter', downloaderStatusFooter);\n\nfunction downloaderStatusFooter() {\n controller.$inject = [\"$scope\", \"$http\", \"RequestsErrorHandler\", \"HydraAuthService\", \"$interval\", \"bootstrapped\"];\n return {\n templateUrl: 'static/html/directives/downloader-status-footer.html',\n controller: controller\n };\n\n function controller($scope, $http, RequestsErrorHandler, HydraAuthService, $interval, bootstrapped) {\n\n var downloaderStatus;\n var updateInterval = null;\n console.log(\"websocket\");\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\n var stompClient = Stomp.over(socket);\n stompClient.debug = null;\n stompClient.connect({}, function (frame) {\n stompClient.subscribe('/topic/downloaderStatus', function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n });\n stompClient.send(\"/app/connectDownloaderStatus\", function (message) {\n downloaderStatus = JSON.parse(message.body);\n updateFooter(downloaderStatus);\n })\n });\n\n\n $scope.$emit(\"showDownloaderStatus\", true);\n var downloadRateCounter = 0;\n\n $scope.downloaderChart = {\n options: {\n chart: {\n type: 'stackedAreaChart',\n height: 35,\n width: 300,\n margin: {\n top: 5,\n right: 0,\n bottom: 0,\n left: 0\n },\n x: function (d) {\n return d.x;\n },\n y: function (d) {\n return d.y;\n },\n interactive: true,\n useInteractiveGuideline: false,\n transitionDuration: 0,\n showControls: false,\n showLegend: false,\n showValues: false,\n duration: 0,\n tooltip: {\n valueFormatter: function (d, i) {\n return d + \" kb/s\";\n },\n keyFormatter: function () {\n return \"\";\n },\n id: \"downloader-status-tooltip\"\n },\n css: \"float:right;\"\n }\n },\n data: [{values: [], key: \"Bla\", color: '#00a950'}],\n config: {\n refreshDataOnly: true,\n deepWatchDataDepth: 0,\n deepWatchData: false,\n deepWatchOptions: false\n }\n };\n\n function updateFooter() {\n if (downloaderStatus.lastUpdateForNow && updateInterval === null) {\n //Server will send no new status updates for a while because the last two retrieved statuses are the same.\n //We must still update the footer so that the graph doesn't stand still\n console.debug(\"Retrieved last update for now, starting update interval\");\n updateInterval = $interval(function () {\n //Just put the last known rate at the end to keep it going\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if (_.every($scope.downloaderChart.data[0].values, function (value) {\n return value === downloaderStatus.lastDownloadRate\n })) {\n //The bar has been filled with the latest known value, we can now stop until we get a new update\n console.debug(\"Filled the bar with last known value, stopping update interval\");\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n }, 1000);\n } else if (updateInterval !== null && !downloaderStatus.lastUpdateForNow) {\n //New data is incoming, cancel interval\n console.debug(\"Got new update, stopping update interval\")\n $interval.cancel(updateInterval);\n updateInterval = null;\n }\n\n $scope.foo = downloaderStatus;\n $scope.foo.downloaderImage = downloaderStatus.downloaderType === 'NZBGET' ? 'nzbgetlogo' : 'sabnzbdlogo';\n $scope.foo.url = downloaderStatus.url;\n //We need to splice the variable with the rates because it's watched by angular and when overwriting it we would lose the watch and it wouldn't be updated\n var maxEntriesHistory = 200;\n if ($scope.downloaderChart.data[0].values.length < maxEntriesHistory) {\n //Not yet full, just fill up\n console.debug(\"Adding data, filling bar with initial values\")\n for (var i = $scope.downloaderChart.data[0].values.length; i < maxEntriesHistory; i++) {\n if (i >= downloaderStatus.downloadingRatesInKilobytes.length) {\n break;\n }\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.downloadingRatesInKilobytes[i]});\n }\n } else {\n console.debug(\"Adding data, moving bar\")\n //Remove first one, add to the end\n $scope.downloaderChart.data[0].values.splice(0, 1);\n $scope.downloaderChart.data[0].values.push({x: downloadRateCounter++, y: downloaderStatus.lastDownloadRate});\n }\n try {\n $scope.api.update();\n } catch (ignored) {\n }\n if ($scope.foo.state === \"DOWNLOADING\") {\n $scope.foo.buttonClass = \"play\";\n } else if ($scope.foo.state === \"PAUSED\") {\n $scope.foo.buttonClass = \"pause\";\n } else if ($scope.foo.state === \"OFFLINE\") {\n $scope.foo.buttonClass = \"off\";\n } else {\n $scope.foo.buttonClass = \"time\";\n }\n $scope.foo.state = $scope.foo.state.substr(0, 1) + $scope.foo.state.substr(1).toLowerCase();\n //Bad but without the state isn't updated\n $scope.$apply();\n }\n\n }\n}\n\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbzipButton', downloadNzbzipButton);\r\n\r\nfunction downloadNzbzipButton() {\r\n controller.$inject = [\"$scope\", \"growl\", \"$http\", \"FileDownloadService\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbzip-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n searchTitle: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope, growl, $http, FileDownloadService) {\r\n $scope.download = function () {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n var values = _.map($scope.searchResults, function (value) {\r\n return value.searchResultId;\r\n });\r\n var link = \"internalapi/nzbzip\";\r\n\r\n var searchTitle;\r\n if (angular.isDefined($scope.searchTitle)) {\r\n searchTitle = \" for \" + $scope.searchTitle.replace(\"[^a-zA-Z0-9.-]\", \"_\");\r\n } else {\r\n searchTitle = \"\";\r\n }\r\n var filename = \"NZBHydra NZBs\" + searchTitle + \".zip\";\r\n $http({method: \"post\", url: link, data: values}).then(function (response) {\r\n if (response.data.successful && response.data.zip !== null) {\r\n link = \"internalapi/nzbzipDownload\";\r\n FileDownloadService.downloadFile(link, filename, \"POST\", response.data.zipFilepath);\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n if (response.data.missedIds.length > 0) {\r\n growl.error(\"Unable to add \" + response.missedIds.length + \" out of \" + values.length + \" NZBs to ZIP\");\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n }\r\n }\r\n }\r\n}\r\n\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('downloadNzbsButton', downloadNzbsButton);\r\n\r\nfunction downloadNzbsButton() {\r\n controller.$inject = [\"$scope\", \"$http\", \"NzbDownloadService\", \"ConfigService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/download-nzbs-button.html',\r\n require: ['^searchResults'],\r\n scope: {\r\n searchResults: \"<\",\r\n callback: \"&\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, $http, NzbDownloadService, ConfigService, growl) {\r\n\r\n $scope.downloaders = NzbDownloadService.getEnabledDownloaders();\r\n $scope.blackholeEnabled = ConfigService.getSafe().downloading.saveTorrentsTo !== null;\r\n\r\n $scope.download = function (downloader) {\r\n if (angular.isUndefined($scope.searchResults) || $scope.searchResults.length === 0) {\r\n growl.info(\"You should select at least one result...\");\r\n } else {\r\n\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"NZB\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending torrent result to downloader\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were NZBs. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are torrent results which were skipped\");\r\n }\r\n\r\n var tos = _.map(searchResults, function (entry) {\r\n return {searchResultId: entry.searchResultId, originalCategory: entry.originalCategory}\r\n });\r\n\r\n NzbDownloadService.download(downloader, tos).then(function (response) {\r\n if (angular.isDefined(response.data)) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful) {\r\n if (response.data.message == null) {\r\n growl.info(\"Successfully added all NZBs\");\r\n } else {\r\n growl.warning(response.data.message);\r\n }\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n growl.error(\"Error while adding NZBs\");\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n }\r\n }, function () {\r\n growl.error(\"Error while adding NZBs\");\r\n });\r\n }\r\n };\r\n\r\n $scope.sendToBlackhole = function () {\r\n var didFilterOutResults = false;\r\n var didKeepAnyResults = false;\r\n var searchResults = _.filter($scope.searchResults, function (value) {\r\n if (value.downloadType === \"TORRENT\") {\r\n didKeepAnyResults = true;\r\n return true;\r\n } else {\r\n console.log(\"Not sending NZB result to black hole\");\r\n didFilterOutResults = true;\r\n return false;\r\n }\r\n });\r\n if (didFilterOutResults && !didKeepAnyResults) {\r\n growl.info(\"None of the selected results were torrents. Adding aborted\");\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: []});\r\n }\r\n return;\r\n } else if (didFilterOutResults && didKeepAnyResults) {\r\n growl.info(\"Some the selected results are NZB results which were skipped\");\r\n }\r\n var searchResultIds = _.pluck(searchResults, \"searchResultId\");\r\n $http.put(\"internalapi/saveTorrent\", searchResultIds).then(function (response) {\r\n if (response.data.successful) {\r\n growl.info(\"Successfully saved all torrents\");\r\n } else {\r\n growl.error(response.data.message);\r\n }\r\n if (angular.isDefined($scope.callback)) {\r\n $scope.callback({result: response.data.addedIds});\r\n }\r\n });\r\n }\r\n\r\n }\r\n}\r\n\r\n","\r\nfreetextFilter.$inject = [\"DebugService\"];\r\nbooleanFilter.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp').directive(\"columnFilterWrapper\", columnFilterWrapper);\r\n\r\nfunction columnFilterWrapper() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: 'static/html/dataTable/columnFilterOuter.html',\r\n transclude: true,\r\n controllerAs: 'columnFilterWrapperCtrl',\r\n scope: {\r\n inline: \"@\"\r\n },\r\n bindToController: true,\r\n controller: controller,\r\n link: function (scope, element, attr, ctrl) {\r\n scope.element = element;\r\n }\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n var vm = this;\r\n\r\n vm.open = false;\r\n vm.isActive = false;\r\n\r\n vm.toggle = function () {\r\n vm.open = !vm.open;\r\n if (vm.open) {\r\n $scope.$broadcast(\"opened\");\r\n }\r\n };\r\n\r\n vm.clear = function () {\r\n if (vm.open) {\r\n $scope.$broadcast(\"clear\");\r\n }\r\n };\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive, open) {\r\n vm.open = open || false;\r\n vm.isActive = isActive;\r\n });\r\n\r\n DebugService.log(\"filter-wrapper\");\r\n }\r\n\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"freetextFilter\", freetextFilter);\r\n\r\nfunction freetextFilter(DebugService) {\r\n controller.$inject = [\"$scope\", \"focus\"];\r\n return {\r\n template: '',\r\n require: \"^columnFilterWrapper\",\r\n controllerAs: 'innerController',\r\n scope: {\r\n column: \"@\",\r\n onKey: \"@\",\r\n placeholder: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, focus) {\r\n $scope.inline = $scope.$parent.$parent.columnFilterWrapperCtrl.inline; //Hacky way of getting the value from the outer wrapper\r\n $scope.data = {};\r\n $scope.tooltip = $scope.tooltip || \"\";\r\n\r\n $scope.$on(\"opened\", function () {\r\n focus(\"freetext-filter-input\");\r\n });\r\n\r\n function emitFilterEvent(isOpen) {\r\n isOpen = $scope.inline || isOpen;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.data.filter,\r\n filterType: \"freetext\"\r\n }, angular.isDefined($scope.data.filter) && $scope.data.filter.length > 0, isOpen);\r\n }\r\n\r\n $scope.$on(\"clear\", function () {\r\n //Don't clear but close window (event is fired when clicked outside)\r\n emitFilterEvent(false);\r\n });\r\n\r\n $scope.onKeyUp = function (keyEvent) {\r\n if (keyEvent.which === 13 || $scope.onKey) {\r\n emitFilterEvent($scope.onKey && keyEvent.which !== 13); //Keep open if triggered by key, close always when enter pressed\r\n }\r\n };\r\n DebugService.log(\"filter-freetext\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"checkboxesFilter\", checkboxesFilter);\r\n\r\nfunction checkboxesFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n controllerAs: 'checkboxesFilterController',\r\n scope: {\r\n column: \"@\",\r\n entries: \"<\",\r\n preselect: \"<\",\r\n showInvert: \"<\",\r\n isBoolean: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.selected = {\r\n entries: []\r\n };\r\n $scope.active = false;\r\n\r\n if ($scope.preselect) {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n }\r\n\r\n $scope.invert = function () {\r\n $scope.selected.entries = _.difference($scope.entries, $scope.selected.entries);\r\n };\r\n\r\n $scope.selectAll = function () {\r\n $scope.selected.entries.push.apply($scope.selected.entries, $scope.entries);\r\n };\r\n\r\n $scope.deselectAll = function () {\r\n $scope.selected.entries.splice(0, $scope.selected.entries.length);\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.entries.length < $scope.entries.length;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: _.pluck($scope.selected.entries, \"id\"),\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selectAll();\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"checkboxes\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-checkboxes\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"booleanFilter\", booleanFilter);\r\n\r\nfunction booleanFilter(DebugService) {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n template: '',\r\n controllerAs: 'booleanFilterController',\r\n scope: {\r\n column: \"@\",\r\n options: \"<\",\r\n preselect: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n\r\n function controller($scope) {\r\n $scope.selected = {value: $scope.options[$scope.preselect].value};\r\n $scope.active = false;\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.value !== $scope.options[0].value;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.selected.value,\r\n filterType: \"boolean\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.value = true;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"boolean\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-boolean\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"timeFilter\", timeFilter);\r\n\r\nfunction timeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n selected: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n $scope.active = false;\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.apply = function () {\r\n $scope.active = $scope.selected.beforeDate || $scope.selected.afterDate;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: {\r\n after: $scope.selected.afterDate,\r\n before: $scope.selected.beforeDate\r\n }, filterType: \"time\"\r\n }, $scope.active)\r\n };\r\n $scope.clear = function () {\r\n $scope.selected.beforeDate = undefined;\r\n $scope.selected.afterDate = undefined;\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {filterValue: undefined, filterType: \"time\"}, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n DebugService.log(\"filter-time\");\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"numberRangeFilter\", numberRangeFilter);\r\n\r\nfunction numberRangeFilter() {\r\n controller.$inject = [\"$scope\", \"DebugService\"];\r\n return {\r\n template: '',\r\n scope: {\r\n column: \"@\",\r\n min: \"<\",\r\n max: \"<\",\r\n addon: \"@\",\r\n tooltip: \"@\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, DebugService) {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n\r\n function apply() {\r\n $scope.active = $scope.filterValue.min || $scope.filterValue.max;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: $scope.filterValue,\r\n filterType: \"numberRange\"\r\n }, $scope.active)\r\n }\r\n\r\n $scope.clear = function () {\r\n $scope.filterValue = {min: undefined, max: undefined};\r\n $scope.active = false;\r\n $scope.$emit(\"filter\", $scope.column, {\r\n filterValue: undefined,\r\n filterType: \"numberRange\",\r\n isBoolean: $scope.isBoolean\r\n }, $scope.active)\r\n };\r\n $scope.$on(\"clear\", $scope.clear);\r\n\r\n $scope.apply = function () {\r\n apply();\r\n };\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n apply();\r\n }\r\n };\r\n\r\n DebugService.log(\"filter-number\");\r\n }\r\n}\r\n\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"columnSortable\", columnSortable);\r\n\r\nfunction columnSortable() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n restrict: \"E\",\r\n templateUrl: \"static/html/dataTable/columnSortable.html\",\r\n transclude: true,\r\n scope: {\r\n sortMode: \"<\", //0: no sorting, 1: asc, 2: desc\r\n column: \"@\",\r\n reversed: \"<\",\r\n startMode: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n if (angular.isUndefined($scope.sortMode)) {\r\n $scope.sortMode = 0;\r\n }\r\n\r\n if (angular.isUndefined($scope.startMode)) {\r\n $scope.startMode = 1;\r\n }\r\n\r\n $scope.sortModel = {\r\n sortMode: $scope.sortMode,\r\n column: $scope.column,\r\n reversed: $scope.reversed,\r\n startMode: $scope.startMode,\r\n active: false\r\n };\r\n\r\n $scope.$on(\"newSortColumn\", function (event, column, sortMode) {\r\n $scope.sortModel.active = column === $scope.sortModel.column;\r\n if (column !== $scope.sortModel.column) {\r\n $scope.sortModel.sortMode = 0;\r\n } else {\r\n $scope.sortModel.sortMode = sortMode;\r\n }\r\n });\r\n\r\n $scope.sort = function () {\r\n if ($scope.sortModel.sortMode === 0 || angular.isUndefined($scope.sortModel.sortMode)) {\r\n $scope.sortModel.sortMode = $scope.sortModel.startMode;\r\n } else if ($scope.sortModel.sortMode === 1) {\r\n $scope.sortModel.sortMode = 2;\r\n } else {\r\n $scope.sortModel.sortMode = 1;\r\n }\r\n $scope.$emit(\"sort\", $scope.sortModel.column, $scope.sortModel.sortMode, $scope.sortModel.reversed)\r\n };\r\n\r\n }\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('connectionTest', connectionTest);\r\n\r\nfunction connectionTest() {\r\n controller.$inject = [\"$scope\"];\r\n return {\r\n templateUrl: 'static/html/directives/connection-test.html',\r\n require: ['^type', '^data'],\r\n scope: {\r\n type: \"=\",\r\n id: \"=\",\r\n data: \"=\",\r\n downloader: \"=\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope) {\r\n $scope.message = \"\";\r\n\r\n\r\n var testButton = \"#button-test-connection\";\r\n var testMessage = \"#message-test-connection\";\r\n\r\n function showSuccess() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-danger\");\r\n angular.element(testButton).addClass(\"btn-success\");\r\n }\r\n\r\n function showError() {\r\n angular.element(testButton).removeClass(\"btn-default\");\r\n angular.element(testButton).removeClass(\"btn-success\");\r\n angular.element(testButton).addClass(\"btn-danger\");\r\n }\r\n\r\n $scope.testConnection = function () {\r\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\r\n var myInjector = angular.injector([\"ng\"]);\r\n var $http = myInjector.get(\"$http\");\r\n var url;\r\n var params;\r\n if ($scope.type === \"downloader\") {\r\n url = \"internalapi/test_downloader\";\r\n params = {name: $scope.downloader, username: $scope.data.username, password: $scope.data.password};\r\n if ($scope.downloader === \"SABNZBD\") {\r\n params.apiKey = $scope.data.apiKey;\r\n params.url = $scope.data.url;\r\n } else {\r\n params.host = $scope.data.host;\r\n params.port = $scope.data.port;\r\n params.ssl = $scope.data.ssl;\r\n }\r\n } else if ($scope.data.type === \"newznab\") {\r\n url = \"internalapi/test_newznab\";\r\n params = {host: $scope.data.host, apiKey: $scope.data.apiKey};\r\n if (angular.isDefined($scope.data.username)) {\r\n params[\"username\"] = $scope.data.username;\r\n params[\"password\"] = $scope.data.password;\r\n }\r\n }\r\n $http.get(url, {params: params}).then(function (result) {\r\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\r\n if (result.successful) {\r\n angular.element(testMessage).text(\"\");\r\n showSuccess();\r\n } else {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n\r\n }, function () {\r\n angular.element(testMessage).text(result.message);\r\n showError();\r\n }\r\n ).finally(function () {\r\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\r\n })\r\n }\r\n\r\n }\r\n}\r\n\r\n","//Taken from https://github.com/IamAdamJowett/angular-click-outside\r\n\r\nclickOutside.$inject = [\"$document\", \"$parse\", \"$timeout\"];\r\nfunction childOf(/*child node*/c, /*parent node*/p) { //returns boolean\r\n while ((c = c.parentNode) && c !== p) ;\r\n return !!c;\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive(\"clickOutside\", clickOutside);\r\n\r\n/**\r\n * @ngdoc directive\r\n * @name angular-click-outside.directive:clickOutside\r\n * @description Directive to add click outside capabilities to DOM elements\r\n * @requires $document\r\n * @requires $parse\r\n * @requires $timeout\r\n **/\r\nfunction clickOutside($document, $parse, $timeout) {\r\n return {\r\n restrict: 'A',\r\n link: function ($scope, elem, attr) {\r\n\r\n // postpone linking to next digest to allow for unique id generation\r\n $timeout(function () {\r\n var classList = (attr.outsideIfNot !== undefined) ? attr.outsideIfNot.split(/[ ,]+/) : [],\r\n fn;\r\n\r\n function eventHandler(e) {\r\n var i,\r\n element,\r\n r,\r\n id,\r\n classNames,\r\n l;\r\n\r\n // check if our element already hidden and abort if so\r\n if (angular.element(elem).hasClass(\"ng-hide\")) {\r\n return;\r\n }\r\n\r\n // if there is no click target, no point going on\r\n if (!e || !e.target) {\r\n return;\r\n }\r\n\r\n if (angular.isDefined(attr.outsideIgnore) && $scope.$eval(attr.outsideIgnore)) {\r\n return;\r\n }\r\n var isChild = childOf(e.target, elem.context);\r\n if (isChild) {\r\n return;\r\n }\r\n // loop through the available elements, looking for classes in the class list that might match and so will eat\r\n for (element = e.target; element; element = element.parentNode) {\r\n // check if the element is the same element the directive is attached to and exit if so (props @CosticaPuntaru)\r\n if (element === elem[0]) {\r\n return;\r\n }\r\n\r\n // now we have done the initial checks, start gathering id's and classes\r\n id = element.id,\r\n classNames = element.className,\r\n l = classList.length;\r\n\r\n // Unwrap SVGAnimatedString classes\r\n if (classNames && classNames.baseVal !== undefined) {\r\n classNames = classNames.baseVal;\r\n }\r\n\r\n // if there are no class names on the element clicked, skip the check\r\n if (classNames || id) {\r\n\r\n // loop through the elements id's and classnames looking for exceptions\r\n for (i = 0; i < l; i++) {\r\n //prepare regex for class word matching\r\n r = new RegExp('\\\\b' + classList[i] + '\\\\b');\r\n\r\n // check for exact matches on id's or classes, but only if they exist in the first place\r\n if ((id !== undefined && id === classList[i]) || (classNames && r.test(classNames))) {\r\n // now let's exit out as it is an element that has been defined as being ignored for clicking outside\r\n return;\r\n }\r\n }\r\n }\r\n }\r\n\r\n // if we have got this far, then we are good to go with processing the command passed in via the click-outside attribute\r\n $timeout(function () {\r\n fn = $parse(attr['clickOutside']);\r\n fn($scope, {event: e});\r\n });\r\n }\r\n\r\n // if the devices has a touchscreen, listen for this event\r\n if (_hasTouch()) {\r\n $document.on('touchstart', eventHandler);\r\n }\r\n\r\n // still listen for the click event even if there is touch to cater for touchscreen laptops\r\n $document.on('click', eventHandler);\r\n\r\n // when the scope is destroyed, clean up the documents event handlers as we don't want it hanging around\r\n $scope.$on('$destroy', function () {\r\n if (_hasTouch()) {\r\n $document.off('touchstart', eventHandler);\r\n }\r\n\r\n $document.off('click', eventHandler);\r\n });\r\n\r\n /**\r\n * @description Private function to attempt to figure out if we are on a touch device\r\n * @private\r\n **/\r\n function _hasTouch() {\r\n // works on most browsers, IE10/11 and Surface\r\n return 'ontouchstart' in window || navigator.maxTouchPoints;\r\n }\r\n });\r\n }\r\n };\r\n}\r\n","angular\r\n .module('nzbhydraApp')\r\n .directive('cfgFormEntry', cfgFormEntry);\r\n\r\nfunction cfgFormEntry() {\r\n return {\r\n templateUrl: 'static/html/directives/cfg-form-entry.html',\r\n require: [\"^title\", \"^cfg\"],\r\n scope: {\r\n title: \"@\",\r\n cfg: \"=\",\r\n help: \"@\",\r\n type: \"@?\",\r\n options: \"=?\"\r\n },\r\n controller: [\"$scope\", \"$element\", \"$attrs\", function ($scope, $element, $attrs) {\r\n $scope.type = angular.isDefined($scope.type) ? $scope.type : 'text';\r\n $scope.options = angular.isDefined($scope.type) ? $scope.$eval($attrs.options) : [];\r\n }]\r\n };\r\n}","angular\n .module('nzbhydraApp')\n .directive('hydrabackup', hydrabackup);\n\nfunction hydrabackup() {\n controller.$inject = [\"$scope\", \"BackupService\", \"Upload\", \"FileDownloadService\", \"$http\", \"RequestsErrorHandler\", \"growl\", \"RestartService\"];\n return {\n templateUrl: 'static/html/directives/backup.html',\n controller: controller\n };\n\n function controller($scope, BackupService, Upload, FileDownloadService, $http, RequestsErrorHandler, growl, RestartService) {\n $scope.refreshBackupList = function () {\n BackupService.getBackupsList().then(function (backups) {\n $scope.backups = backups;\n });\n };\n\n $scope.refreshBackupList();\n\n $scope.uploadActive = false;\n\n\n $scope.createBackupFile = function () {\n $http.get(\"internalapi/backup/backuponly\", {params: {dontdownload: true}}).then(function () {\n $scope.refreshBackupList();\n });\n };\n $scope.createAndDownloadBackupFile = function () {\n FileDownloadService.downloadFile(\"internalapi/backup/backup\", \"nzbhydra-backup-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\", \"GET\").then(function () {\n $scope.refreshBackupList();\n });\n };\n\n $scope.uploadBackupFile = function (file, errFiles) {\n RequestsErrorHandler.specificallyHandled(function () {\n\n $scope.file = file;\n $scope.errFile = errFiles && errFiles[0];\n if (file) {\n $scope.uploadActive = true;\n file.upload = Upload.upload({\n url: 'internalapi/backup/restorefile',\n file: file\n });\n\n file.upload.then(function (response) {\n if (response.data.successful) {\n $scope.uploadActive = false;\n RestartService.startCountdown(\"Upload successful. Restarting for wrapper to restore data.\");\n } else {\n file.progress = 0;\n growl.error(response.data.message)\n }\n\n }, function (response) {\n growl.error(response.data.message)\n }, function (evt) {\n file.progress = Math.min(100, parseInt(100.0 * evt.loaded / evt.total));\n file.loaded = Math.floor(evt.loaded / 1024);\n file.total = Math.floor(evt.total / 1024);\n });\n }\n });\n };\n\n $scope.restoreFromFile = function (filename) {\n BackupService.restoreFromFile(filename).then(function () {\n RestartService.startCountdown(\"Extraction of backup successful. Restarting for wrapper to restore data.\");\n },\n function (response) {\n growl.error(response.data);\n })\n }\n\n }\n}\n\n","\naddableNzbs.$inject = [\"DebugService\"];angular\n .module('nzbhydraApp')\n .directive('addableNzbs', addableNzbs);\n\nfunction addableNzbs(DebugService) {\n controller.$inject = [\"$scope\", \"NzbDownloadService\"];\n return {\n templateUrl: 'static/html/directives/addable-nzbs.html',\n require: [],\n scope: {\n searchresult: \"<\",\n alwaysAsk: \"<\"\n },\n controller: controller\n };\n\n function controller($scope, NzbDownloadService) {\n $scope.alwaysAsk = $scope.alwaysAsk === \"true\";\n $scope.downloaders = _.filter(NzbDownloadService.getEnabledDownloaders(), function (downloader) {\n if ($scope.searchresult.downloadType !== \"NZB\") {\n return downloader.downloadType === $scope.searchresult.downloadType\n }\n return true;\n });\n }\n}\n","\r\naddableNzb.$inject = [\"DebugService\"];angular\r\n .module('nzbhydraApp')\r\n .directive('addableNzb', addableNzb);\r\n\r\nfunction addableNzb(DebugService) {\r\n controller.$inject = [\"$scope\", \"NzbDownloadService\", \"growl\"];\r\n return {\r\n templateUrl: 'static/html/directives/addable-nzb.html',\r\n scope: {\r\n searchresult: \"=\",\r\n downloader: \"<\",\r\n alwaysAsk: \"<\"\r\n },\r\n controller: controller\r\n };\r\n\r\n function controller($scope, NzbDownloadService, growl) {\r\n if (!_.isNullOrEmpty($scope.downloader.iconCssClass)) {\r\n $scope.cssClass = \"fa fa-\" + $scope.downloader.iconCssClass.replace(\"fa-\", \"\").replace(\"fa \", \"\");\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd\" : \"nzbget\";\r\n }\r\n\r\n $scope.add = function () {\r\n var originalClass = $scope.cssClass;\r\n $scope.cssClass = \"nzb-spinning\";\r\n NzbDownloadService.download($scope.downloader, [{\r\n searchResultId: $scope.searchresult.searchResultId ? $scope.searchresult.searchResultId : $scope.searchresult.id,\r\n originalCategory: $scope.searchresult.originalCategory,\r\n mappedCategory: $scope.searchresult.category\r\n }], $scope.alwaysAsk).then(function (response) {\r\n if (response !== \"dismissed\") {\r\n if (response.data.successful && (response.data.addedIds != null && response.data.addedIds.indexOf(Number($scope.searchresult.searchResultId)) > -1)) {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-success\" : \"nzbget-success\";\r\n } else {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(response.data.message);\r\n }\r\n } else {\r\n $scope.cssClass = originalClass;\r\n }\r\n }, function () {\r\n $scope.cssClass = $scope.downloader.downloaderType === \"SABNZBD\" ? \"sabnzbd-error\" : \"nzbget-error\";\r\n growl.error(\"An unexpected error occurred while trying to contact NZBHydra or add the NZB.\");\r\n })\r\n };\r\n }\r\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nCheckCapsModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"$http\", \"$timeout\", \"growl\", \"capsCheckRequest\"];\nIndexerConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nIndexerCheckBeforeCloseService.$inject = [\"$q\", \"ModalService\", \"IndexerConfigBoxService\", \"growl\", \"blockUI\"];\nfunction regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n}\n\nfunction getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService) {\n var fieldset = [];\n if (indexerModel.searchModuleType === \"TORZNAB\") {\n fieldset.push({\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\"Torznab indexers can only be used for internal searches or dedicated searches using /torznab/api\"]\n }\n });\n }\n if ((indexerModel.searchModuleType === \"NEWZNAB\" || indexerModel.searchModuleType === \"TORZNAB\") && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var message;\n var cssClass;\n if (!indexerModel.configComplete) {\n message = \"The config of this indexer is incomplete. Please click the button at the bottom to check its capabilities and complete its configuration.\";\n cssClass = \"alert alert-danger\";\n } else {\n message = \"The capabilities of this indexer were not checked completely. Some actually supported search types or IDs may not be usable.\";\n cssClass = \"alert alert-warning\";\n }\n fieldset.push({\n type: 'help',\n hideExpression: 'model.allCapsChecked && model.configComplete',\n templateOptions: {\n type: 'help',\n lines: [message],\n class: cssClass\n }\n });\n }\n\n var stateHelp = \"\";\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\" || indexerModel.state === \"DISABLED_SYSTEM\") {\n if (indexerModel.state === \"DISABLED_SYSTEM_TEMPORARY\") {\n stateHelp = \"The indexer was disabled by the program due to an error. It will be reenabled automatically or you can enable it manually\";\n } else {\n stateHelp = \"The indexer was disabled by the program due to error from which it cannot recover by itself. Try checking the caps to make sure it works or just enable it and see what happens.\";\n }\n }\n\n if (indexerModel.searchModuleType === 'NEWZNAB' || indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push(\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== indexerModel.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Indexer \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n },\n noComma:\n {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return value.indexOf(\",\") === -1;\n }\n return true;\n },\n message: '\"Name may not contain a comma\"'\n }\n }\n })\n }\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push({\n key: 'state',\n type: 'horizontalIndexerStateSwitch',\n templateOptions: {\n type: 'switch',\n label: 'State',\n help: stateHelp\n }\n });\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n var hostField = {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'http://www.someindexer.com'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n };\n if (indexerModel.searchModuleType === 'TORZNAB') {\n hostField.templateOptions.help = 'If you use Jackett and have an external URL use that one';\n }\n fieldset.push(\n hostField\n );\n }\n\n if (['WTFNZB', 'NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG', 'NZBINDEX_API'].includes(indexerModel.searchModuleType) && indexerModel.host !== 'https://feed.animetosho.org') {\n fieldset.push(\n {\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'apiPath',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API path',\n help: 'Path to the API. If empty /api is used',\n required: false,\n advanced: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB', 'JACKETT_CONFIG'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Username',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n }\n\n if ('WTFNZB' === indexerModel.searchModuleType) {\n fieldset.push(\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: true,\n label: 'Username',\n help: 'See the API help on the website. Copy the user ID from the example API request where it says i=<yourUserId> (e.g. ABg4Cd==)'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'password',\n type: 'passwordSwitch',\n hideExpression: '!model.username',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Password',\n help: 'Only needed if indexer requires HTTP auth for API access (rare).'\n }\n }\n )\n }\n\n\n if (indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'score',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Priority',\n required: true,\n help: 'When duplicate search results are found the result from the indexer with the highest number will be selected.',\n tooltip: 'The priority determines which indexer is used if duplicate results are found (i.e. results that link to the same upload, not just results with the same name).
                  The result from the indexer with the highest number is shown first in the GUI and returned for API searches.'\n\n }\n });\n }\n\n fieldset.push(\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout',\n min: 1,\n help: 'Supercedes the general timeout in \"Searching\".',\n advanced: true\n }\n },\n {\n key: 'schedule',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Schedule',\n help: 'Determines when an indexer should be selected. See wiki. You can enter multiple time spans. Apply values with return key.',\n advanced: true\n }\n }\n );\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'hitLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'API hit limit',\n help: 'Maximum number of API hits since \"API hit reset time\".',\n tooltip: 'When the maximum number of API hits is reached the indexer isn\\'t used anymore. Only API hits done by NZBHydra are taken into account.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n },\n {\n key: 'downloadLimit',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Download limit',\n help: 'When # of downloads since \"Hit reset time\" is reached indexer will not be searched.'\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 0;\n },\n message: '\"Value must be greater than 0\"'\n }\n }\n }\n );\n fieldset.push(\n {\n key: 'hitLimitResetTime',\n type: 'horizontalInput',\n hideExpression: '!model.hitLimit && !model.downloadLimit',\n templateOptions: {\n type: 'number',\n label: 'Hit reset time',\n help: 'UTC hour of day at which the API hit counter is reset (0-23). Leave empty for a rolling reset counter.',\n tooltip: 'Either define the time of day when the counter is reset by the indexer or leave it empty to use a rolling reset counter, meaning the number of hits for the last 24h at the time of the search is limited.'\n },\n validators: {\n timeOfDay: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return value >= 0 && value <= 23;\n },\n message: '$viewValue + \" is not a valid hour of day (0-23)\"'\n }\n }\n },\n {\n key: 'loadLimitOnRandom',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Load limiting',\n help: 'If set indexer will only be picked for one out of x API searches (on average).',\n tooltip: 'For indexers with a low API hit limit you can enable load limiting. Define any number n so that the indexer will only be used for searches in 1/n cases (on average). For example if you define a load limit of 5 the indexer will only be picked every fifth search.',\n advanced: true\n },\n validators: {\n greaterThanZero: {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n return _.isNullOrEmpty(value) || value > 1;\n },\n message: '\"Value must be greater than 1\"'\n }\n }\n }\n );\n }\n if (indexerModel.searchModuleType === 'TORZNAB') {\n fieldset.push({\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored. Supercedes any setting made in the searching config.'\n }\n })\n }\n\n if (['NEWZNAB', 'TORZNAB', 'WTFNZB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'User agent',\n help: 'Rarely needed. Will supercede the one in the main searching settings.',\n advanced: true\n }\n }\n )\n }\n\n if (['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) {\n fieldset.push(\n {\n key: 'customParameters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n required: false,\n label: 'Custom parameters',\n help: 'Define custom parameters to be sent to the indexer when searching. Use the format \"name=value\"Apply values with return key.',\n advanced: 'true'\n }\n }\n )\n }\n\n fieldset.push(\n {\n key: 'preselect',\n type: 'horizontalSwitch',\n hideExpression: 'model.enabledForSearchSource===\"EXTERNAL\"',\n templateOptions: {\n type: 'switch',\n label: 'Preselect',\n help: 'Preselect this indexer on the search page.'\n }\n }\n );\n fieldset.push(\n {\n key: 'enabledForSearchSource',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Enable for...',\n options: [\n {name: 'Internal searches only', value: 'INTERNAL'},\n {name: 'API searches only', value: 'API'},\n {name: 'All but API update queries ', value: 'ALL_BUT_RSS'},\n {name: 'Only API update queries ', value: 'ONLY_RSS'},\n {name: 'Internal and any API searches', value: 'BOTH'}\n ],\n help: 'Select for which searches this indexer will be used. \"Update queries\" are searches without query or ID (e.g. done by Sonarr periodically).',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'color',\n type: 'colorInput',\n templateOptions: {\n label: 'Color',\n help: 'If set it will be used in the search results to mark the indexer\\'s results.',\n tooltip: 'To mark expanded results they\\'re shown in a darker shade so it\\'s recommended to use indexer colors which not only differ in lightness',\n advanced: true\n }\n }\n );\n\n fieldset.push(\n {\n key: 'vipExpirationDate',\n type: 'horizontalInput',\n templateOptions: {\n required: false,\n label: 'VIP expiry',\n help: 'Enter when your VIP access expires and NZBHydra will track it and warn you when close to expiry. Enter as YYYY-MM-DD or \"Lifetime\".'\n },\n validators: {\n port: regexValidator(/^(\\d{4}-\\d{2}-\\d{2})|Lifetime$/, \"is no valid date (must be 'YYYY-MM-DD' or 'Lifetime')\", true, false)\n }\n }\n );\n\n if (indexerModel.searchModuleType !== \"ANIZB\" && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n var cats = CategoriesService.getWithoutAll();\n var options = _.map(cats, function (x) {\n return {id: x.name, label: x.name}\n });\n fieldset.push(\n {\n key: 'enabledCategories',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Categories',\n help: 'Only use indexer when searching for these and also reject results from others. Selecting none equals selecting all.',\n options: options,\n settings: {\n showSelectedValues: false,\n noSelectedText: \"None/All\"\n },\n advanced: true\n }\n }\n );\n }\n\n\n if ((['NEWZNAB', 'TORZNAB'].includes(indexerModel.searchModuleType)) && !isInitial && indexerModel.searchModuleType !== 'JACKETT_CONFIG') {\n fieldset.push(\n {\n key: 'supportedSearchIds',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search IDs',\n options: [\n {label: 'IMDB (TV)', id: 'TVIMDB'},\n {label: 'TVDB', id: 'TVDB'},\n {label: 'TVRage', id: 'TVRAGE'},\n {label: 'Trakt', id: 'TRAKT'},\n {label: 'TVMaze', id: 'TVMAZE'},\n {label: 'IMDB', id: 'IMDB'},\n {label: 'TMDB', id: 'TMDB'}\n ],\n noSelectedText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n key: 'supportedSearchTypes',\n type: 'horizontalMultiselect',\n templateOptions: {\n label: 'Search types',\n options: [\n {label: 'Audio', id: 'AUDIO'},\n {label: 'Ebooks', id: 'BOOK'},\n {label: 'Movies', id: 'MOVIE'},\n {label: 'Search', id: 'SEARCH'},\n {label: 'TV', id: 'TVSEARCH'}\n ],\n buttonText: \"None\",\n advanced: true\n }\n }\n );\n fieldset.push(\n {\n type: 'horizontalCheckCaps',\n hideExpression: '!model.host || !model.name',\n templateOptions: {\n label: 'Check capabilities',\n help: 'Find out what search types and IDs the indexer supports.',\n tooltip: 'The first time an indexer is added the connection is tested. When successful the supported search IDs and types are checked. These determine if indexers allow searching for movies, shows or ebooks using meta data like the IMDB id or the author and title. Newznab indexers cannot be used until this check was completed. Click this button to execute the caps check again.'\n }\n }\n )\n }\n\n if (indexerModel.searchModuleType === 'NZBINDEX') {\n fieldset.push(\n {\n key: 'generalMinSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Min size',\n help: 'NZBIndex returns a lot of crap with small file sizes. Set this value and all smaller results will be filtered out no matter the category'\n }\n }\n );\n }\n\n if (indexerModel.searchModuleType === 'BINSEARCH') {\n fieldset.push({\n key: 'binsearchOtherGroups',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Search in other groups',\n help: 'If disabled binsearch will only search in the most popular usenet groups'\n }\n })\n }\n\n return fieldset;\n}\n\nfunction _showBox(indexerModel, parentModel, isInitial, $uibModal, CategoriesService, mode, form, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-box.html',\n controller: 'IndexerConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n indexerModel.showAdvanced = parentModel.showAdvanced;\n return indexerModel;\n },\n fields: function () {\n return getIndexerBoxFields(indexerModel, parentModel, isInitial, CategoriesService, mode);\n },\n form: function () {\n return form;\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n }\n ,\n info: function () {\n return indexerModel.info;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'indexers',\n templateUrl: 'static/html/config/indexer-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService) {\n $scope.showBox = showBox;\n $scope.formOptions = {formState: $scope.formState};\n $scope.showPresetSelection = showPresetSelection;\n\n function showPresetSelection() {\n $uibModal.open({\n templateUrl: 'static/html/config/indexer-config-selection.html',\n controller: 'IndexerConfigSelectionBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n return $scope.model;\n },\n form: function () {\n return $scope.form;\n }\n }\n });\n }\n\n //Called when clicking the box of an existing indexer\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", $scope.form)\n }\n\n }\n });\n }]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigSelectionBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$uibModal\", \"$http\", \"model\", \"form\", \"growl\", \"CategoriesService\", \"$timeout\", \"ModalService\", \"RequestsErrorHandler\", function ($scope, $q, $uibModalInstance, $uibModal, $http, model, form, growl, CategoriesService, $timeout, ModalService, RequestsErrorHandler) {\n\n $scope.showBox = showBox;\n $scope.isInitial = false;\n\n $scope.select = function (modelPreset) {\n\n addEntry(modelPreset);\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n $scope.readJackettConfig = function () {\n var indexerModel = createIndexerModel();\n indexerModel.searchModuleType = \"JACKETT_CONFIG\";\n indexerModel.isInitial = false;\n indexerModel.host = \"http://127.0.0.1:9117\";\n indexerModel.name = \"Jackett config\";\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"jackettConfig\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //User pushed button, now we read the config\n RequestsErrorHandler.specificallyHandled(function () {\n $http.post(\"internalapi/indexer/readJackettConfig\", {existingIndexers: model, jackettConfig: returnedModel}, {\n headers: {\n \"Accept\": \"application/json;charset=utf-8\",\n \"Accept-Charset\": \"charset=utf-8\"\n }\n }).then(function (response) {\n //Replace model with new result\n model.splice(0, model.length);\n _.each(response.data.newIndexersConfig, function (x) {\n model.push(x);\n });\n growl.info(\"Added \" + response.data.addedTrackers + \" new trackers from Jackett\");\n growl.info(\"Updated \" + response.data.updatedTrackers + \" trackers from Jackett\");\n\n }, function (response) {\n ModalService.open(\"Error reading jackett config\", response.data, {}, \"md\", \"left\");\n });\n });\n }\n });\n\n $timeout(function () {\n $uibModalInstance.close();\n },\n 200);\n };\n\n function showBox(indexerModel, model) {\n _showBox(indexerModel, model, false, $uibModal, CategoriesService, \"indexer\", form)\n }\n\n function createIndexerModel() {\n return angular.copy({\n allCapsChecked: false,\n apiKey: null,\n backend: 'NEWZNAB',\n color: null,\n configComplete: false,\n categoryMapping: null,\n downloadLimit: null,\n enabledCategories: [],\n enabledForSearchSource: \"BOTH\",\n generalMinSize: null,\n hitLimit: null,\n hitLimitResetTime: 0,\n host: null,\n loadLimitOnRandom: null,\n name: null,\n password: null,\n preselect: true,\n score: 0,\n searchModuleType: 'NEWZNAB',\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n timeout: null,\n username: null,\n userAgent: null\n });\n }\n\n function addEntry(preset) {\n if (checkAddingAllowed(model, preset)) {\n var indexerModel = createIndexerModel();\n if (angular.isDefined(preset)) {\n _.extend(indexerModel, preset);\n }\n\n $scope.isInitial = true;\n\n _showBox(indexerModel, model, true, $uibModal, CategoriesService, \"indexer\", form, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n model.push(angular.isDefined(returnedModel) ? returnedModel : indexerModel);\n }\n });\n } else {\n growl.error(\"That predefined indexer is already configured.\"); //For now this is the only case where adding is forbidden so we use this hardcoded message \"for now\"... (;-))\n }\n }\n\n function checkAddingAllowed(existingIndexers, preset) {\n if (!preset || !(preset.searchModuleType === \"ANIZB\" || preset.searchModuleType === \"BINSEARCH\" || preset.searchModuleType === \"NZBINDEX\" || preset.searchModuleType === \"NZBCLUB\")) {\n return true;\n }\n return !_.any(existingIndexers, function (existingEntry) {\n return existingEntry.name === preset.name;\n });\n }\n\n $scope.newznabPresets = [\n {\n name: \"abNZB\",\n host: \"https://abnzb.com/\"\n },\n {\n name: \"altHUB\",\n host: \"https://api.althub.co.za\"\n },\n {\n name: \"Animetosho (Newznab)\",\n host: \"https://feed.animetosho.org\",\n categories: [\"Anime\"],\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n allCapsChecked: true,\n configComplete: true,\n categoryMapping: {\n anime: 5070,\n audiobook: null,\n comic: null,\n ebook: null,\n magazine: null,\n categories: [\n {\n id: 5070,\n name: \"Anime\",\n subCategories: []\n }\n ]\n }\n },\n {\n name: \"Digital Carnage\",\n host: \"https://digitalcarnage.info\"\n },\n {\n name: \"DogNZB\",\n host: \"https://api.dognzb.cr\"\n },\n {\n name: \"Drunken Slug\",\n host: \"https://api.drunkenslug.com\"\n },\n {\n name: \"FastNZB\",\n host: \"https://fastnzb.com\"\n },\n {\n name: \"LuluNZB\",\n host: \"https://lulunzb.com\"\n },\n {\n name: \"miatrix\",\n host: \"https://www.miatrix.com\"\n },\n {\n name: \"NZB Finder\",\n host: \"https://nzbfinder.ws\"\n },\n {\n name: \"NZBCat\",\n host: \"https://nzb.cat\"\n },\n {\n name: \"nzb.su\",\n host: \"https://api.nzb.su\"\n },\n {\n name: \"NZBGeek\",\n host: \"https://api.nzbgeek.info\"\n },\n {\n name: \"NzbNdx\",\n host: \"https://www.nzbndx.com\"\n },\n {\n name: \"NzBNooB\",\n host: \"https://www.nzbnoob.com\"\n },\n {\n name: \"NzbNation\",\n host: \"http://www.nzbnation.com/\"\n },\n {\n name: \"nzbplanet\",\n host: \"https://nzbplanet.net\"\n },\n {\n name: \"omgwtfnzbs\",\n host: \"https://api.omgwtfnzbs.org\"\n },\n {\n name: \"SceneNZBs\",\n host: \"https://scenenzbs.com\",\n info: \"If you want german or spanish (or other language specific) results make sure to add the newznab IDs in the categories config.
                  For example for german UHD movies add 2145.
                  You can find out the IDs by browsing https://scenenzbs.com/rss.\"\n },\n {\n name: \"spotweb.com\",\n host: \"https://spotweb.me\"\n },\n {\n name: \"Tabula-Rasa\",\n host: \"https://www.tabula-rasa.pw/api/v1/\"\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://binsearch.info\",\n loadLimitOnRandom: null,\n name: \"Binsearch\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"BINSEARCH\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://api.nzbindex.com\",\n loadLimitOnRandom: null,\n name: \"NZBIndex API\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_API\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://beta.nzbindex.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBIndex Beta\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBINDEX_BETA\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n hitLimit: null,\n hitLimitResetTime: null,\n host: \"https://www.nzbking.com/search\",\n loadLimitOnRandom: null,\n name: \"NZBKing.com\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"NZBKING\",\n username: null\n },\n {\n allCapsChecked: true,\n enabledForSearchSource: \"INTERNAL\",\n categories: [],\n configComplete: true,\n downloadLimit: null,\n generalMinSize: 1,\n hitLimit: null,\n hitLimitResetTime: null,\n host: null,\n loadLimitOnRandom: null,\n name: \"WtfNzb\",\n password: null,\n preselect: true,\n score: 0,\n showOnSearch: true,\n state: \"ENABLED\",\n supportedSearchIds: [],\n supportedSearchTypes: [],\n timeout: null,\n searchModuleType: \"WTFNZB\",\n username: null,\n userAgent: null\n }\n ];\n\n $scope.newznabPresets = _.sortBy($scope.newznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n\n $scope.torznabPresets = [\n {\n allCapsChecked: false,\n configComplete: false,\n name: \"Jackett/Cardigann\",\n host: \"http://127.0.0.1:9117/api/v2.0/indexers/YOURTRACKER/results/torznab/\",\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n },\n {\n categories: [\"Anime\"],\n allCapsChecked: true,\n configComplete: true,\n name: \"Animetosho (Torznab)\",\n host: \"https://feed.animetosho.org\",\n supportedSearchIds: [],\n supportedSearchTypes: [\"SEARCH\"],\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n }\n ];\n\n $scope.emptyTorznabPreset = {\n allCapsChecked: false,\n configComplete: false,\n supportedSearchIds: undefined,\n supportedSearchTypes: undefined,\n searchModuleType: \"TORZNAB\",\n state: \"ENABLED\",\n enabledForSearchSource: \"BOTH\"\n };\n $scope.torznabPresets = _.sortBy($scope.torznabPresets, function (entry) {\n return entry.name.toLowerCase()\n });\n}]);\n\n\nangular.module('nzbhydraApp').controller('IndexerConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"form\", \"fields\", \"isInitial\", \"parentModel\", \"growl\", \"IndexerCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, form, fields, isInitial, parentModel, growl, IndexerCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n $uibModalInstance.close(model);\n } else if (form.$valid) {\n var a = IndexerCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach(form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .controller('CheckCapsModalInstanceCtrl', CheckCapsModalInstanceCtrl);\n\nfunction CheckCapsModalInstanceCtrl($scope, $interval, $http, $timeout, growl, capsCheckRequest) {\n\n var updateMessagesInterval = undefined;\n\n $scope.messages = undefined;\n $http.post(\"internalapi/indexer/checkCaps\", capsCheckRequest).then(function (response) {\n $scope.$close([response.data, capsCheckRequest.indexerConfig]);\n if (response.data.length === 0) {\n growl.info(\"No indexers were checked\");\n }\n }, function () {\n $scope.$dismiss(\"Unknown error\")\n });\n\n $timeout(\n updateMessagesInterval = $interval(function () {\n $http.get(\"internalapi/indexer/checkCapsMessages\").then(function (response) {\n var map = response.data;\n var messages = [];\n for (var name in map) {\n if (map.hasOwnProperty(name)) {\n for (var i = 0; i < map[name].length; i++) {\n var message = \"\";\n if (capsCheckRequest.checkType !== \"SINGLE\") {\n message += name + \": \";\n }\n message += map[name][i];\n messages.push(message);\n }\n }\n }\n $scope.messages = messages;\n });\n\n }, 500),\n 500);\n\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMessagesInterval)) {\n $interval.cancel(updateMessagesInterval);\n }\n });\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerConfigBoxService', IndexerConfigBoxService);\n\nfunction IndexerConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n\n}\n\nangular\n .module('nzbhydraApp')\n .factory('IndexerCheckBeforeCloseService', IndexerCheckBeforeCloseService);\n\nfunction IndexerCheckBeforeCloseService($q, ModalService, IndexerConfigBoxService, growl, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (model.searchModuleType === 'JACKETT_CONFIG') {\n deferred.resolve(model);\n } else if (!scope.isInitial && (!scope.needsConnectionTest || scope.form.capsChecked)) {\n checkCapsWhenClosing(scope, model).then(function () {\n deferred.resolve(model);\n }, function () {\n deferred.reject();\n });\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/indexer/checkConnection\";\n IndexerConfigBoxService.checkConnection(url, model).then(function () {\n growl.info(\"Connection to the indexer tested successfully\");\n checkCapsWhenClosing(scope, model).then(function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.resolve(data);\n }, function () {\n scope.spinnerActive = false;\n blockUI.reset();\n deferred.reject();\n });\n },\n function (data) {\n scope.spinnerActive = false;\n blockUI.reset();\n handleConnectionCheckFail(ModalService, data, model, \"indexer\", deferred);\n });\n }\n return deferred.promise;\n }\n\n //Called when the indexer dialog is closed\n function checkCapsWhenClosing(scope, model) {\n var deferred = $q.defer();\n if (angular.isUndefined(model.supportedSearchIds) || angular.isUndefined(model.supportedSearchTypes)) {\n\n blockUI.start(\"New indexer found. Testing its capabilities. This may take a bit...\");\n IndexerConfigBoxService.checkCaps({indexerConfig: model, checkType: \"SINGLE\"}).then(\n function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n blockUI.reset();\n scope.spinnerActive = false;\n if (data.allCapsChecked && data.configComplete) {\n growl.info(\"Successfully tested capabilites of indexer\");\n } else if (!data.allCapsChecked && data.configComplete) {\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
                  Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n } else if (!data.configComplete) {\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n\n deferred.resolve(data.indexerConfig);\n },\n function () {\n blockUI.reset();\n scope.spinnerActive = false;\n model.supportedSearchIds = undefined;\n model.supportedSearchTypes = undefined;\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually using the button below.\", {}, \"md\", \"left\");\n deferred.resolve();\n }).finally(\n function () {\n scope.spinnerActive = false;\n })\n } else {\n deferred.resolve();\n }\n return deferred.promise;\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\nDownloaderConfigBoxService.$inject = [\"$http\", \"$q\", \"$uibModal\"];\nDownloaderCheckBeforeCloseService.$inject = [\"$q\", \"DownloaderConfigBoxService\", \"growl\", \"ModalService\", \"blockUI\"];\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n\n formlyConfigProvider.setType({\n name: 'downloaderConfig',\n templateUrl: 'static/html/config/downloader-config.html',\n controller: function ($scope, $uibModal, growl, CategoriesService, localStorageService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope._showBox = _showBox;\n $scope.showBox = showBox;\n $scope.isInitial = false;\n $scope.presets = [\n {\n name: \"NZBGet\",\n downloaderType: \"NZBGET\",\n username: \"nzbgetx\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\",\n url: \"http://nzbget:tegbzn6789@localhost:6789\"\n },\n {\n url: \"http://localhost:8080\",\n downloaderType: \"SABNZBD\",\n name: \"SABnzbd\",\n nzbAddingType: \"UPLOAD\",\n nzbAccessType: \"REDIRECT\",\n iconCssClass: \"\",\n downloadType: \"NZB\"\n }\n ];\n\n function _showBox(model, parentModel, isInitial, callback) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/config/downloader-config-box.html',\n controller: 'DownloaderConfigBoxInstanceController',\n size: 'lg',\n resolve: {\n model: function () {\n //Isn't properly stored in parentmodel for some reason, this works just as well\n model.showAdvanced = localStorageService.get(\"showAdvanced\");\n console.log(model.showAdvanced);\n return model;\n },\n fields: function () {\n return getDownloaderBoxFields(model, parentModel, isInitial, angular.injector(), CategoriesService);\n },\n isInitial: function () {\n return isInitial\n },\n parentModel: function () {\n return parentModel;\n },\n data: function () {\n return $scope.options.data;\n }\n }\n });\n\n\n modalInstance.result.then(function (returnedModel) {\n $scope.form.$setDirty(true);\n if (angular.isDefined(callback)) {\n callback(true, returnedModel);\n }\n }, function () {\n if (angular.isDefined(callback)) {\n callback(false);\n }\n });\n }\n\n function showBox(model, parentModel) {\n $scope._showBox(model, parentModel, false)\n }\n\n $scope.addEntry = function (entriesCollection, preset) {\n var model = angular.copy({\n enabled: true\n });\n if (angular.isDefined(preset)) {\n _.extend(model, preset);\n }\n\n $scope.isInitial = true;\n\n $scope._showBox(model, entriesCollection, true, function (isSubmitted, returnedModel) {\n if (isSubmitted) {\n //Here is where the entry is actually added to the model\n entriesCollection.push(angular.isDefined(returnedModel) ? returnedModel : model);\n }\n });\n };\n\n function getDownloaderBoxFields(model, parentModel, isInitial) {\n var fieldset = [];\n\n fieldset = _.union(fieldset, [\n {\n key: 'enabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Enabled'\n }\n },\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n required: true\n },\n validators: {\n uniqueName: {\n expression: function (viewValue) {\n if (isInitial || viewValue !== model.name) {\n return _.pluck(parentModel, \"name\").indexOf(viewValue) === -1;\n }\n return true;\n },\n message: '\"Downloader \\\\\"\" + $viewValue + \"\\\\\" already exists\"'\n }\n }\n\n },\n {\n key: 'url',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL',\n help: 'URL with scheme and full path',\n required: true\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n }\n ]);\n\n\n if (model.downloaderType === \"SABNZBD\") {\n fieldset.push({\n key: 'apiKey',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'API Key'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n } else if (model.downloaderType === \"NZBGET\") {\n fieldset.push({\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n });\n fieldset.push({\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'text',\n label: 'Password'\n },\n watcher: {\n listener: function (field, newValue, oldValue, scope) {\n if (newValue !== oldValue) {\n scope.$parent.needsConnectionTest = true;\n }\n }\n }\n })\n }\n\n fieldset = _.union(fieldset, [\n {\n key: 'defaultCategory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Default category',\n help: 'When adding NZBs this category will be used instead of asking for the category. Write \"Use original category\", \"Use no category\" or \"Use mapped category\" to not be asked.',\n placeholder: 'Ask when downloading'\n }\n },\n {\n key: 'nzbAddingType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB adding type',\n options: [\n {name: 'Send link', value: 'SEND_LINK'},\n {name: 'Upload NZB', value: 'UPLOAD'}\n ],\n help: \"How NZBs are added to the downloader, either by sending a link to the NZB or by uploading the NZB data.\",\n tooltip: 'You can select if you want to upload the NZB to the downloader or send a Hydra link. The downloader will do the download itself. This is a matter of taste, but adding a link and redirecting the downloader is the fastest way.' +\n '
                  Usually the links are determined using the URL via which you call it in your browser. If your downloader cannot access NZBHydra using that URL you can set a specific URL to be used in the main downloading config.',\n advanced: true\n }\n },\n {\n key: 'addPaused',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Add paused',\n help: 'Add NZBs paused',\n advanced: true\n }\n },\n {\n key: 'iconCssClass',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Icon CSS class',\n help: 'Copy an icon name from https://fontawesome.com/v4.7.0/icons/ (e.g. \"film\")',\n placeholder: 'Default',\n tooltip: 'If you have multiple downloaders of the same type you can select an icon from the Font Awesome library. This icon will be shown in the search results and the NZB download history instead of the default downloader icon.',\n advanced: true\n }\n }\n ]);\n\n return fieldset;\n }\n }\n });\n }]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderConfigBoxService', DownloaderConfigBoxService);\n\nfunction DownloaderConfigBoxService($http, $q, $uibModal) {\n\n return {\n checkConnection: checkConnection,\n checkCaps: checkCaps\n };\n\n function checkConnection(url, settings) {\n var deferred = $q.defer();\n\n $http.post(url, settings).then(function (result) {\n //Using ng-class and a scope variable doesn't work for some reason, is only updated at second click\n if (result.data.successful) {\n deferred.resolve({checked: true, message: null, model: result.data});\n } else {\n deferred.reject({checked: true, message: result.data.message});\n }\n }, function (result) {\n deferred.reject({checked: false, message: result.data.message});\n });\n\n return deferred.promise;\n }\n\n function checkCaps(capsCheckRequest) {\n var deferred = $q.defer();\n\n var result = $uibModal.open({\n templateUrl: 'static/html/checker-state.html',\n controller: CheckCapsModalInstanceCtrl,\n size: \"md\",\n backdrop: \"static\",\n backdropClass: \"waiting-cursor\",\n resolve: {\n capsCheckRequest: function () {\n return capsCheckRequest;\n }\n }\n });\n\n result.result.then(function (data) {\n deferred.resolve(data[0], data[1]);\n }, function (message) {\n deferred.reject(message);\n });\n\n return deferred.promise;\n }\n}\n\nangular.module('nzbhydraApp').controller('DownloaderConfigBoxInstanceController', [\"$scope\", \"$q\", \"$uibModalInstance\", \"$http\", \"model\", \"fields\", \"isInitial\", \"parentModel\", \"data\", \"growl\", \"DownloaderCheckBeforeCloseService\", function ($scope, $q, $uibModalInstance, $http, model, fields, isInitial, parentModel, data, growl, DownloaderCheckBeforeCloseService) {\n\n $scope.model = model;\n $scope.fields = fields;\n $scope.isInitial = isInitial;\n $scope.spinnerActive = false;\n $scope.needsConnectionTest = false;\n\n $scope.obSubmit = function () {\n if ($scope.form.$valid) {\n var a = DownloaderCheckBeforeCloseService.checkBeforeClose($scope, model).then(function (data) {\n if (angular.isDefined(data)) {\n $scope.model = data;\n }\n $uibModalInstance.close(data);\n });\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n angular.forEach($scope.form.$error, function (error) {\n angular.forEach(error, function (field) {\n field.$setTouched();\n });\n });\n }\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.deleteEntry = function () {\n parentModel.splice(parentModel.indexOf(model), 1);\n $uibModalInstance.close($scope);\n };\n\n $scope.reset = function () {\n if (angular.isDefined(data.resetFunction)) {\n //Reset the model twice (for some reason when we do it once the search types / ids fields are empty, resetting again fixes that... (wtf))\n $scope.options.resetModel();\n $scope.options.resetModel();\n }\n };\n\n $scope.$on(\"modal.closing\", function (targetScope, reason) {\n if (reason === \"backdrop click\") {\n $scope.reset($scope);\n }\n });\n}]);\n\n\nangular\n .module('nzbhydraApp')\n .factory('DownloaderCheckBeforeCloseService', DownloaderCheckBeforeCloseService);\n\nfunction DownloaderCheckBeforeCloseService($q, DownloaderConfigBoxService, growl, ModalService, blockUI) {\n\n return {\n checkBeforeClose: checkBeforeClose\n };\n\n function checkBeforeClose(scope, model) {\n var deferred = $q.defer();\n if (!scope.isInitial && !scope.needsConnectionTest) {\n deferred.resolve();\n } else {\n scope.spinnerActive = true;\n blockUI.start(\"Testing connection...\");\n var url = \"internalapi/downloader/checkConnection\";\n DownloaderConfigBoxService.checkConnection(url, JSON.stringify(model)).then(function () {\n blockUI.reset();\n scope.spinnerActive = false;\n growl.info(\"Connection to the downloader tested successfully\");\n deferred.resolve();\n },\n function (data) {\n blockUI.reset();\n scope.spinnerActive = false;\n handleConnectionCheckFail(ModalService, data, model, \"downloader\", deferred);\n }).finally(function () {\n scope.spinnerActive = false;\n blockUI.reset();\n });\n }\n return deferred.promise;\n }\n}","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nhashCode = function (s) {\n return s.split(\"\").reduce(function (a, b) {\n a = ((a << 5) - a) + b.charCodeAt(0);\n return a & a\n }, 0);\n};\n\nangular\n .module('nzbhydraApp').run([\"formlyConfig\", \"formlyValidationMessages\", function (formlyConfig, formlyValidationMessages) {\n formlyValidationMessages.addStringMessage('required', 'This field is required');\n formlyValidationMessages.addStringMessage('newznabCategories', 'Invalid');\n formlyConfig.extras.errorExistsAndShouldBeVisibleExpression = 'fc.$touched || form.$submitted';\n}]);\n\nangular\n .module('nzbhydraApp')\n .config([\"formlyConfigProvider\", function config(formlyConfigProvider) {\n formlyConfigProvider.extras.removeChromeAutoComplete = true;\n formlyConfigProvider.extras.explicitAsync = true;\n formlyConfigProvider.disableWarnings = window.onProd;\n\n\n formlyConfigProvider.setWrapper({\n name: 'settingWrapper',\n templateUrl: 'setting-wrapper.html'\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'fieldset',\n templateUrl: 'fieldset-wrapper.html',\n controller: ['$scope', function ($scope) {\n $scope.tooltipIsOpen = false;\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'help',\n template: [\n '
                  ',\n '
                  ',\n '
                  ',\n '
                  {{ line | derefererExtracting | unsafe }}
                  ',\n '
                  ',\n '
                  ',\n '
                  '\n ].join(' ')\n });\n\n\n formlyConfigProvider.setWrapper({\n name: 'logicalGroup',\n template: [\n ''\n ].join(' ')\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalInput',\n extends: 'input',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTextArea',\n extends: 'textarea',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'timeOfDay',\n extends: 'horizontalInput',\n controller: ['$scope', function ($scope) {\n $scope.model[$scope.options.key] = moment.utc($scope.model[$scope.options.key]).toDate();\n }]\n });\n\n formlyConfigProvider.setType({\n name: 'passwordSwitch',\n extends: 'horizontalInput',\n template: [\n '
                  ',\n '',\n '',\n '',\n '
                  '\n ].join(' '),\n controller: function ($scope) {\n $scope.hidePassword = true;\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalChips',\n extends: 'horizontalInput',\n template: '' +\n ' ' +\n '
                  ' +\n ' {{chip}}' +\n ' ' +\n '
                  ' +\n '
                  ' +\n ' ' +\n '
                  '\n });\n\n formlyConfigProvider.setType({\n name: 'percentInput',\n template: [\n ''\n ].join(' ')\n });\n\n formlyConfigProvider.setType({\n name: 'apiKeyInput',\n template: [\n '
                  ',\n '',\n '',\n '',\n '
                  '\n ].join(' '),\n controller: function ($scope) {\n $scope.generate = function () {\n var result = \"\";\n var length = 24;\n var chars = \"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\";\n for (var i = length; i > 0; --i) result += chars[Math.floor(Math.random() * chars.length)];\n $scope.model[$scope.options.key] = result;\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'fileInput',\n extends: 'horizontalInput',\n template: [\n '
                  ',\n '',\n '',\n '',\n '
                  '\n ].join(' '),\n controller: function ($scope, FileSelectionService) {\n $scope.open = function () {\n FileSelectionService.open($scope.model[$scope.options.key], $scope.to.type).then(function (selection) {\n $scope.model[$scope.options.key] = selection;\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'colorInput',\n extends: 'horizontalInput',\n templateUrl: 'static/html/config/color-control.html',\n controller: function ($scope) {\n //Model format: rgb(116,18,18)\n //Input format: rgba(100,42,41,0.5)\n if (!_.isNullOrEmpty($scope.model.color)) {\n $scope.color = $scope.model.color;\n }\n $scope.convertColorToCss = function () {\n if (_.isNullOrEmpty($scope.model.color)) {\n return \"\";\n }\n return $scope.model.color.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\");\n }\n $scope.convertColorFromInput = function () {\n if (_.isNullOrEmpty($scope.color)) {\n return;\n }\n $scope.model.color = $scope.color.replace(\"rgba\", \"rgb\").replace(\",0.5)\", \")\");\n }\n $scope.clear = function () {\n $scope.model.color = null;\n $scope.color = null;\n }\n $scope.$watch(\"model.color\", function () {\n if (!_.isNullOrEmpty($scope.model.color)) {\n $scope.color = $scope.model.color;\n }\n })\n }\n });\n\n formlyConfigProvider.setType({\n name: 'testConnection',\n templateUrl: 'button-test-connection.html'\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestConnection',\n extends: 'testConnection',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'customMappingTest',\n extends: 'horizontalInput',\n template: [\n '
                  ',\n '',\n '
                  '\n ].join(' '),\n controller: function ($scope, $uibModal, $http) {\n $scope.open = function () {\n var model = $scope.model;\n var modelCopy = structuredClone(model);\n $uibModal.open({\n templateUrl: 'static/html/custom-mapping-help.html',\n controller: [\"$scope\", \"$uibModalInstance\", \"$http\", function ($scope, $uibModalInstance, $http) {\n $scope.model = modelCopy;\n $scope.cancel = function () {\n $uibModalInstance.close();\n }\n $scope.submit = function () {\n Object.assign(model, $scope.model)\n $uibModalInstance.close();\n\n }\n\n $scope.test = function () {\n if (!$scope.exampleInput) {\n $scope.exampleResult = \"Empty example data\";\n return;\n\n }\n console.log(\"custom mapping test\");\n $http.post('internalapi/customMapping/test', {mapping: $scope.model, exampleInput: $scope.exampleInput, matchAll: $scope.matchAll}).then(function (response) {\n console.log(response.data);\n console.log(response.data.output);\n if (response.data.error) {\n $scope.exampleResult = response.data.error;\n } else if (response.data.match) {\n $scope.exampleResult = response.data.output;\n } else {\n $scope.exampleResult = \"Input does not match example\";\n }\n }, function (response) {\n $scope.exampleResult = response.message;\n })\n }\n }],\n size: \"md\"\n })\n }\n }\n });\n\n function updateIndexerModel(model, indexerConfig) {\n model.supportedSearchIds = indexerConfig.supportedSearchIds;\n model.supportedSearchTypes = indexerConfig.supportedSearchTypes;\n model.categoryMapping = indexerConfig.categoryMapping;\n model.configComplete = indexerConfig.configComplete;\n model.allCapsChecked = indexerConfig.allCapsChecked;\n model.hitLimit = indexerConfig.hitLimit;\n model.downloadLimit = indexerConfig.downloadLimit;\n model.state = indexerConfig.state;\n model.backend = indexerConfig.backend;\n }\n\n formlyConfigProvider.setType({\n //BUtton\n name: 'checkCaps',\n templateUrl: 'button-check-caps.html',\n controller: function ($scope, IndexerConfigBoxService, ModalService, growl) {\n $scope.message = \"\";\n $scope.uniqueId = hashCode($scope.model.name) + hashCode($scope.model.host);\n\n var testButton = \"#button-check-caps-\" + $scope.uniqueId;\n var testMessage = \"#message-check-caps-\" + $scope.uniqueId;\n\n function showSuccess() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).addClass(\"btn-success\");\n }\n\n function showError() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-warning\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-danger\");\n }\n\n function showWarning() {\n angular.element(testButton).removeClass(\"btn-default\");\n angular.element(testButton).removeClass(\"btn-danger\");\n angular.element(testButton).removeClass(\"btn-success\");\n angular.element(testButton).addClass(\"btn-warning\");\n }\n\n\n //When button is clicked\n $scope.checkCaps = function () {\n angular.element(testButton).addClass(\"glyphicon-refresh-animate\");\n IndexerConfigBoxService.checkCaps({\n indexerConfig: $scope.model,\n checkType: \"SINGLE\"\n }).then(function (data) {\n data = data[0]; //We get a list of results (with one result because the check type is single)\n //Formly doesn't allow replacing the model so we need to set all the relevant values ourselves\n updateIndexerModel($scope.model, data.indexerConfig);\n if (data.indexerConfig.supportedSearchIds.length > 0) {\n var message = \"Supports \" + data.indexerConfig.supportedSearchIds;\n angular.element(testMessage).text(message);\n }\n if (data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showSuccess();\n growl.info(\"Successfully tested capabilites of indexer\");\n $scope.form.capsChecked = true;\n } else if (!data.indexerConfig.allCapsChecked && data.indexerConfig.configComplete) {\n showWarning();\n ModalService.open(\"Incomplete caps check\", \"The capabilities of the indexer could not be checked completely. You may use it but it's recommended to repeat the check at another time.
                  Until then some search types or IDs may not be usable.\", {}, \"md\", \"left\");\n $scope.form.capsChecked = true;\n } else if (!data.configComplete) {\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }\n }, function (message) {\n angular.element(testMessage).text(message);\n showError();\n ModalService.open(\"Error testing capabilities\", \"An error occurred while contacting the indexer. It will not be usable until the caps check has been executed. You can trigger it manually from the indexer config box\", {}, \"md\", \"left\");\n }).finally(function () {\n angular.element(testButton).removeClass(\"glyphicon-refresh-animate\");\n });\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalCheckCaps',\n extends: 'checkCaps',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalApiKeyInput',\n extends: 'apiKeyInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalPercentInput',\n extends: 'percentInput',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'switch',\n template: '
                  '\n });\n\n formlyConfigProvider.setType({\n name: 'indexerStateSwitch',\n template: ''\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalIndexerStateSwitch',\n extends: 'indexerStateSwitch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n formlyConfigProvider.setType({\n name: 'duoSetting',\n extends: 'input',\n defaultOptions: {\n className: 'col-md-9',\n templateOptions: {\n type: 'number',\n noRow: true,\n label: ''\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSwitch',\n extends: 'switch',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalSelect',\n extends: 'select',\n wrapper: ['settingWrapper', 'bootstrapHasError'],\n controller: function ($scope) {\n if ($scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n if ($scope.options.templateOptions.optionsFunctionAfter !== undefined) {\n $scope.options.templateOptions.optionsFunctionAfter($scope.model);\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'horizontalMultiselect',\n defaultOptions: {\n templateOptions: {\n optionsAttr: 'bs-options',\n ngOptions: 'option[to.valueProp] as option in to.options | filter: $select.search'\n }\n },\n template: '',\n controller: function ($scope) {\n var settings = $scope.to.settings || [];\n settings.classes = settings.classes || [];\n angular.extend(settings.classes, [\"form-control\"]);\n $scope.settings = settings;\n if ($scope.options.templateOptions.optionsFunction !== null && $scope.options.templateOptions.optionsFunction !== undefined) {\n $scope.to.options.push.apply($scope.to.options, $scope.options.templateOptions.optionsFunction($scope.model));\n }\n $scope.events = {\n onToggleItem: function (item, newValue) {\n $scope.form.$setDirty(true);\n }\n }\n },\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n formlyConfigProvider.setType({\n name: 'label',\n template: ''\n });\n\n formlyConfigProvider.setType({\n name: 'duolabel',\n extends: 'label',\n defaultOptions: {\n className: 'col-md-2',\n templateOptions: {\n label: '-'\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'repeatSection',\n templateUrl: 'repeatSection.html',\n controller: function ($scope) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(preset) {\n console.log(preset);\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n Object.assign(newsection, preset);\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'recheckAllCaps',\n templateUrl: 'static/html/config/recheck-all-caps.html',\n controller: function ($scope, $uibModal, growl, IndexerConfigBoxService) {\n $scope.recheck = function (checkType) {\n IndexerConfigBoxService.checkCaps({checkType: checkType}).then(function (listOfResults) {\n //A bit ugly, but we have to update the current model with the new data from the list\n for (var i = 0; i < $scope.model.length; i++) {\n for (var j = 0; j < listOfResults.length; j++) {\n if ($scope.model[i].name === listOfResults[j].indexerConfig.name) {\n updateIndexerModel($scope.model[i], listOfResults[j].indexerConfig);\n $scope.form.$setDirty(true);\n }\n }\n }\n });\n }\n }\n });\n\n\n formlyConfigProvider.setType({\n name: 'notificationSection',\n templateUrl: 'notificationRepeatSection.html',\n controller: function ($scope, NotificationService) {\n $scope.formOptions = {formState: $scope.formState};\n $scope.addNew = addNew;\n $scope.remove = remove;\n $scope.copyFields = copyFields;\n $scope.eventTypes = [];\n\n var allData = NotificationService.getAllData();\n _.each(_.keys(allData), function (key) {\n $scope.eventTypes.push({\"key\": key, \"label\": allData[key].readable})\n })\n\n function copyFields(fields) {\n fields = angular.copy(fields);\n $scope.repeatfields = fields;\n return fields;\n }\n\n $scope.clear = function (field) {\n return _.mapObject(field, function (key, val) {\n if (typeof val === 'object') {\n return $scope.clear(val);\n }\n return undefined;\n\n });\n };\n\n function addNew(eventType) {\n $scope.form.$setDirty(true);\n $scope.model[$scope.options.key] = $scope.model[$scope.options.key] || [];\n var repeatsection = $scope.model[$scope.options.key];\n var newsection = angular.copy($scope.options.templateOptions.defaultModel);\n\n var eventTypeData = NotificationService.getAllData()[eventType];\n console.log(eventTypeData);\n newsection.eventType = eventType;\n newsection.titleTemplate = eventTypeData.titleTemplate;\n newsection.bodyTemplate = eventTypeData.bodyTemplate;\n newsection.messageType = eventTypeData.messageType;\n\n repeatsection.push(newsection);\n }\n\n function remove($index) {\n $scope.model[$scope.options.key].splice($index, 1);\n $scope.form.$setDirty(true);\n }\n }\n });\n\n formlyConfigProvider.setType({\n //Button\n name: 'testNotification',\n templateUrl: 'button-test-notification.html',\n controller: function ($scope, NotificationService) {\n\n\n //When button is clicked\n $scope.testNotification = function () {\n NotificationService.testNotification($scope.model.eventType)\n }\n }\n });\n\n formlyConfigProvider.setType({\n name: 'horizontalTestNotification',\n extends: 'testNotification',\n wrapper: ['settingWrapper', 'bootstrapHasError']\n });\n\n\n }]);\n\n","\nConfigService.$inject = [\"$http\", \"$q\", \"$cacheFactory\", \"$uibModal\", \"bootstrapped\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('ConfigService', ConfigService);\n\nfunction ConfigService($http, $q, $cacheFactory, $uibModal, bootstrapped, RequestsErrorHandler) {\n\n ConfigureInModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"growl\", \"$interval\", \"RequestsErrorHandler\", \"localStorageService\", \"externalTool\", \"dialogInfo\"];\n var cache = $cacheFactory(\"nzbhydra\");\n var safeConfig = bootstrapped.safeConfig;\n\n return {\n set: set,\n get: get,\n getSafe: getSafe,\n invalidateSafe: invalidateSafe,\n maySeeAdminArea: maySeeAdminArea,\n reloadConfig: reloadConfig,\n apiHelp: apiHelp,\n configureIn: configureIn\n };\n\n function set(newConfig, ignoreWarnings) {\n var deferred = $q.defer();\n $http.put('internalapi/config', newConfig)\n .then(function (response) {\n if (response.data.ok && (ignoreWarnings || response.data.warningMessages.length === 0)) {\n cache.put(\"config\", newConfig);\n setTimeout(function () {\n invalidateSafe();\n }, 500)\n }\n deferred.resolve(response);\n\n }, function (errorresponse) {\n console.log(\"Error saving settings:\");\n console.log(errorresponse);\n deferred.reject(errorresponse);\n });\n return deferred.promise;\n }\n\n function reloadConfig() {\n return $http.get('internalapi/config/reload').then(function (response) {\n return response.data;\n });\n }\n\n function apiHelp() {\n return $http.get('internalapi/config/apiHelp').then(function (response) {\n return response.data;\n });\n }\n\n function get() {\n var config = cache.get(\"config\");\n if (angular.isUndefined(config)) {\n config = $http.get('internalapi/config').then(function (response) {\n return response.data;\n });\n cache.put(\"config\", config);\n }\n\n return config;\n }\n\n function getSafe() {\n return safeConfig;\n }\n\n function invalidateSafe() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get('internalapi/config/safe').then(function (response) {\n safeConfig = response.data;\n });\n });\n\n }\n\n function maySeeAdminArea() {\n function loadAll() {\n var maySeeAdminArea = cache.get(\"maySeeAdminArea\");\n if (!angular.isUndefined(maySeeAdminArea)) {\n var deferred = $q.defer();\n deferred.resolve(maySeeAdminArea);\n return deferred.promise;\n }\n\n return $http.get('internalapi/mayseeadminarea')\n .then(function (configResponse) {\n var config = configResponse.data;\n cache.put(\"maySeeAdminArea\", config);\n return configResponse.data;\n });\n }\n\n return loadAll().then(function (maySeeAdminArea) {\n return maySeeAdminArea;\n });\n }\n\n function configureIn(externalTool) {\n $uibModal.open({\n templateUrl: 'static/html/configure-in-modal.html',\n controller: ConfigureInModalInstanceCtrl,\n size: \"md\",\n resolve: {\n externalTool: function () {\n return externalTool;\n },\n dialogInfo: function () {\n return $http.get(\"internalapi/externalTools/getDialogInfo\").then(function (response) {\n return response.data;\n })\n }\n }\n })\n }\n\n function ConfigureInModalInstanceCtrl($scope, $uibModalInstance, $http, growl, $interval, RequestsErrorHandler, localStorageService, externalTool, dialogInfo) {\n var lastConfig = localStorageService.get(externalTool);\n\n $scope.externalTool = externalTool;\n $scope.externalToolDisplayName = externalTool;\n $scope.externalToolsMessages = [];\n $scope.closeButtonType = \"warning\";\n $scope.completed = false;\n $scope.working = false;\n $scope.showMessages = false;\n\n $scope.nzbhydraHost = dialogInfo.nzbhydraHost;\n $scope.usenetIndexersConfigured = dialogInfo.usenetIndexersConfigured;\n $scope.prioritiesConfigured = dialogInfo.prioritiesConfigured;\n $scope.configureForUsenet = dialogInfo.usenetIndexersConfigured;\n $scope.torrentIndexersConfigured = dialogInfo.torrentIndexersConfigured;\n $scope.configureForTorrents = dialogInfo.torrentIndexersConfigured;\n $scope.addDisabledIndexers = false;\n\n if (!$scope.configureForUsenet && !$scope.configureForTorrents) {\n growl.error(\"No usenet or torrent indexers configured\");\n }\n\n\n $scope.nzbhydraName = \"NZBHydra2\";\n $scope.xdarrHost = \"http://localhost:\"\n $scope.addType = \"SINGLE\";\n $scope.enableRss = true;\n $scope.enableAutomaticSearch = true;\n $scope.enableInteractiveSearch = true;\n $scope.categories = null;\n $scope.animeCategories = null;\n $scope.priority = 0;\n $scope.useHydraPriorities = true;\n\n if (externalTool === \"Sonarr\" || externalTool === \"Sonarrv3\") {\n $scope.xdarrHost += \"8989\";\n $scope.categories = \"5030,5040\";\n if (externalTool === \"Sonarrv3\") {\n $scope.externalToolDisplayName = \"Sonarr v3+\";\n }\n } else if (externalTool === \"Radarr\" || externalTool === \"Radarrv3\") {\n $scope.xdarrHost += \"7878\";\n $scope.categories = \"2000\";\n if (externalTool === \"Radarrv3\") {\n $scope.externalToolDisplayName = \"Radarr v3+\";\n }\n } else if (externalTool === \"Lidarr\") {\n $scope.xdarrHost += \"8686\";\n $scope.categories = \"3000\";\n } else if (externalTool === \"Readarr\") {\n $scope.xdarrHost += \"8787\";\n $scope.categories = \"7020,8010\";\n }\n $scope.removeYearFromSearchString = false;\n\n if (lastConfig !== null && lastConfig !== undefined) {\n Object.assign($scope, lastConfig);\n }\n\n $scope.close = function () {\n $uibModalInstance.dismiss();\n };\n\n $scope.submit = function (deleteOnly) {\n if ($scope.completed && !deleteOnly) {\n $uibModalInstance.dismiss();\n }\n if (!$scope.usenetIndexersConfigured && !$scope.torrentIndexersConfigured && !deleteOnly) {\n growl.error(\"No usenet or torrent indexers configured\");\n return;\n }\n $scope.externalToolsMessages = [];\n $scope.spinnerActive = true;\n $scope.working = true;\n $scope.showMessages = true;\n var data = {\n\n nzbhydraName: $scope.nzbhydraName,\n externalTool: $scope.externalTool,\n nzbhydraHost: $scope.nzbhydraHost,\n addType: deleteOnly ? \"DELETE_ONLY\" : $scope.addType,\n xdarrHost: $scope.xdarrHost,\n xdarrApiKey: $scope.xdarrApiKey,\n enableRss: $scope.enableRss,\n enableAutomaticSearch: $scope.enableAutomaticSearch,\n enableInteractiveSearch: $scope.enableInteractiveSearch,\n categories: $scope.categories,\n animeCategories: $scope.animeCategories,\n removeYearFromSearchString: $scope.removeYearFromSearchString,\n earlyDownloadLimit: $scope.earlyDownloadLimit,\n multiLanguages: $scope.multiLanguages,\n configureForUsenet: $scope.configureForUsenet,\n configureForTorrents: $scope.configureForTorrents,\n additionalParameters: $scope.additionalParameters,\n minimumSeeders: $scope.minimumSeeders,\n seedRatio: $scope.seedRatio,\n seedTime: $scope.seedTime,\n seasonPackSeedTime: $scope.seasonPackSeedTime,\n discographySeedTime: $scope.discographySeedTime,\n addDisabledIndexers: $scope.addDisabledIndexers,\n priority: $scope.priority,\n useHydraPriorities: $scope.useHydraPriorities\n }\n\n localStorageService.set(externalTool, data);\n\n function updateMessages() {\n $http.get(\"internalapi/externalTools/messages\").then(function (response) {\n $scope.externalToolsMessages = response.data;\n });\n }\n\n var updateInterval = $interval(function () {\n updateMessages();\n }, 500);\n\n RequestsErrorHandler.specificallyHandled(function () {\n $scope.completed = false;\n $http.post(\"internalapi/externalTools/configure\", data).then(function (response) {\n updateMessages();\n $interval.cancel(updateInterval);\n $scope.spinnerActive = false;\n console.log(response);\n if (response.data) {\n $scope.completed = true;\n $scope.closeButtonType = \"success\";\n } else {\n $scope.working = false;\n $scope.completed = false;\n }\n }, function (error) {\n updateMessages();\n console.error(error.data);\n $interval.cancel(updateInterval);\n $scope.completed = false;\n $scope.spinnerActive = false;\n $scope.working = false;\n });\n });\n };\n\n }\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nConfigFields.$inject = [\"$injector\"];\nangular\n .module('nzbhydraApp')\n .factory('ConfigFields', ConfigFields);\n\nfunction ConfigFields($injector) {\n return {\n getFields: getFields\n };\n\n function ipValidator() {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n return /^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$/.test(value)\n || /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(value);\n }\n return true;\n },\n message: '$viewValue + \" is not a valid IP Address\"'\n };\n }\n\n function regexValidator(regex, message, prefixViewValue, preventEmpty) {\n return {\n expression: function ($viewValue, $modelValue) {\n var value = $modelValue || $viewValue;\n if (value) {\n if (Array.isArray(value)) {\n for (var i = 0; i < value.length; i++) {\n if (!regex.test(value[i])) {\n return false;\n }\n }\n return true;\n } else {\n return regex.test(value);\n }\n }\n return !preventEmpty;\n },\n message: (prefixViewValue ? '$viewValue + \" ' : '\" ') + message + '\"'\n };\n }\n\n function getFields(rootModel, showAdvanced) {\n return {\n main: [\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Hosting'},\n fieldGroup: [\n {\n key: 'host',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Host',\n required: true,\n placeholder: 'IPv4 address to bind to',\n help: 'I strongly recommend using a reverse proxy instead of exposing this directly. Requires restart.'\n },\n validators: {\n ipAddress: ipValidator()\n }\n },\n {\n key: 'port',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Port',\n required: true,\n placeholder: '5076',\n help: 'Requires restart.'\n },\n validators: {\n port: regexValidator(/^\\d{1,5}$/, \"is no valid port\", true)\n }\n },\n {\n key: 'urlBase',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URL base',\n placeholder: '/nzbhydra',\n help: 'Adapt when using a reverse proxy. See wiki. Always use when calling Hydra, even locally.',\n tooltip: 'If you use Hydra behind a reverse proxy you might want to set the URL base to a value like \"/nzbhydra\". If you accesses Hydra with tools running outside your network (for example from your phone) set the external URL so that it matches the full Hydra URL. That way the NZB links returned in the search results refer to your global URL and not your local address.',\n advanced: true\n },\n validators: {\n urlBase: regexValidator(/^((\\/.*[^\\/])|\\/)$/, 'URL base has to start and may not end with /', false, true)\n }\n\n },\n {\n key: 'ssl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use SSL',\n help: 'Requires restart.',\n tooltip: 'You can use SSL but I recommend using a reverse proxy with SSL. See the wiki for notes regarding reverse proxies and SSL. It\\'s more secure and can be configured better.',\n advanced: true\n }\n },\n {\n key: 'sslKeyStore',\n hideExpression: '!model.ssl',\n type: 'fileInput',\n templateOptions: {\n label: 'SSL keystore file',\n required: true,\n type: \"file\",\n help: 'Requires restart. See wiki.'\n }\n },\n {\n key: 'sslKeyStorePassword',\n hideExpression: '!model.ssl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'password',\n label: 'SSL keystore password',\n required: true,\n help: 'Requires restart.'\n }\n }\n\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Proxy',\n tooltip: 'You can select to use either a SOCKS or an HTTPS proxy. All outside connections will be done via the configured proxy.',\n advanced: true\n }\n ,\n fieldGroup: [\n {\n key: 'proxyType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Use proxy',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'SOCKS', value: 'SOCKS'},\n {name: 'HTTP(S)', value: 'HTTP'}\n ]\n }\n },\n {\n key: 'proxyHost',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'SOCKS proxy host',\n placeholder: 'Set to use a SOCKS proxy',\n help: \"IPv4 only\"\n }\n },\n {\n key: 'proxyPort',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'number',\n label: 'Proxy port',\n placeholder: '1080'\n }\n },\n {\n key: 'proxyUsername',\n type: 'horizontalInput',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy username'\n }\n },\n {\n key: 'proxyPassword',\n type: 'passwordSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n label: 'Proxy password'\n }\n },\n {\n key: 'proxyIgnoreLocal',\n type: 'horizontalSwitch',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'switch',\n label: 'Bypass local network addresses'\n }\n },\n {\n key: 'proxyIgnoreDomains',\n type: 'horizontalChips',\n hideExpression: 'model.proxyType===\"NONE\"',\n templateOptions: {\n type: 'text',\n help: 'Separate by comma. You can use wildcards (*). Case insensitive. Apply values with enter key.',\n label: 'Bypass domains'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'UI'},\n fieldGroup: [\n\n {\n key: 'theme',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Theme',\n options: [\n {name: 'Auto', value: 'auto'},\n {name: 'Grey', value: 'grey'},\n {name: 'Bright', value: 'bright'},\n {name: 'Dark', value: 'dark'}\n ]\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Security'},\n fieldGroup: [\n {\n key: 'apiKey',\n type: 'horizontalApiKeyInput',\n templateOptions: {\n label: 'API key',\n help: 'Alphanumeric only.',\n required: true\n },\n validators: {\n apiKey: regexValidator(/^[a-zA-Z0-9]*$/, \"API key must only contain numbers and digits\", false)\n }\n },\n {\n key: 'dereferer',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Dereferer',\n help: 'Redirect external links to hide your instance. Insert $s for escaped target URL and $us for unescaped target URL. Use empty value to disable.',\n advanced: true\n }\n },\n {\n key: 'verifySsl',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Verify SSL certificates',\n help: 'If enabled only valid/known SSL certificates will be accepted when accessing indexers. Change requires restart. See wiki.',\n advanced: true\n }\n },\n {\n key: 'verifySslDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL for...',\n help: 'Add hosts for which to disable SSL verification. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'disableSslLocally',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Disable SSL locally',\n help: 'Disable SSL for local hosts.',\n advanced: true\n }\n },\n {\n key: 'sniDisabledFor',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Disable SNI',\n help: 'Add a host if you get an \"unrecognized_name\" error. Apply words with return key. See wiki.',\n advanced: true\n }\n },\n {\n key: 'useCsrf',\n type: 'horizontalSwitch',\n templateOptions: {\n label: 'Use CSRF protection',\n help: 'Use CSRF protection.',\n advanced: true\n }\n }\n ]\n },\n\n {\n wrapper: 'fieldset',\n key: 'logging',\n templateOptions: {\n label: 'Logging',\n tooltip: 'The base settings should suffice for most users. If you want you can enable logging of IP adresses for failed logins and NZB downloads.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'logfilelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Logfile level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logMaxHistory',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Max log history',\n help: 'How many daily log files will be kept.'\n }\n },\n {\n key: 'consolelevel',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Console log level',\n options: [\n {name: 'Error', value: 'ERROR'},\n {name: 'Warning', value: 'WARN'},\n {name: 'Info', value: 'INFO'},\n {name: 'Debug', value: 'DEBUG'}\n ],\n help: 'Takes effect on next restart.'\n }\n },\n {\n key: 'logGc',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log GC',\n help: 'Enable garbage collection logging. Only for debugging of memory issues.'\n }\n },\n {\n key: 'logIpAddresses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log IP addresses'\n }\n },\n {\n key: 'mapIpToHost',\n type: 'horizontalSwitch',\n hideExpression: '!model.logIpAddresses',\n templateOptions: {\n type: 'switch',\n label: 'Map hosts',\n help: 'Try to map logged IP addresses to host names.',\n tooltip: 'Enabling this may cause NZBHydra to load very, very slowly when accessed remotely.'\n }\n },\n {\n key: 'logUsername',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Log user names'\n }\n },\n {\n key: 'markersToLog',\n type: 'horizontalMultiselect',\n hideExpression: 'model.consolelevel !== \"DEBUG\" && model.logfilelevel !== \"DEBUG\"',\n templateOptions: {\n label: 'Log markers',\n help: 'Select certain sections for more output on debug level. Please enable only when asked for.',\n options: [\n {label: 'API limits', id: 'LIMITS'},\n {label: 'Category mapping', id: 'CATEGORY_MAPPING'},\n {label: 'Config file handling', id: 'CONFIG_READ_WRITE'},\n {label: 'Custom mapping', id: 'CUSTOM_MAPPING'},\n {label: 'Downloader status updating', id: 'DOWNLOADER_STATUS_UPDATE'},\n {label: 'Duplicate detection', id: 'DUPLICATES'},\n {label: 'External tool configuration', id: 'EXTERNAL_TOOLS'},\n {label: 'History cleanup', id: 'HISTORY_CLEANUP'},\n {label: 'HTTP', id: 'HTTP'},\n {label: 'HTTPS', id: 'HTTPS'},\n {label: 'HTTP Server', id: 'SERVER'},\n {label: 'Indexer scheduler', id: 'SCHEDULER'},\n {label: 'Notifications', id: 'NOTIFICATIONS'},\n {label: 'NZB download status updating', id: 'DOWNLOAD_STATUS_UPDATE'},\n {label: 'Performance', id: 'PERFORMANCE'},\n {label: 'Rejected results', id: 'RESULT_ACCEPTOR'},\n {label: 'Removed trailing words', id: 'TRAILING'},\n {label: 'URL calculation', id: 'URL_CALCULATION'},\n {label: 'User agent mapping', id: 'USER_AGENT'},\n {label: 'VIP expiry', id: 'VIP_EXPIRY'}\n ],\n buttonText: \"None\"\n }\n },\n {\n key: 'historyUserInfoType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'History user info',\n options: [\n {name: 'IP and username', value: 'BOTH'},\n {name: 'IP address', value: 'IP'},\n {name: 'Username', value: 'USERNAME'},\n {name: 'None', value: 'NONE'}\n ],\n help: 'Only affects if value is displayed in the search/download history.',\n hideExpression: '!model.keepHistory'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Backup',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'backupFolder',\n type: 'horizontalInput',\n templateOptions: {\n label: 'Backup folder',\n help: 'Either relative to the NZBHydra data folder or an absolute folder.'\n }\n },\n {\n key: 'backupEveryXDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Backup every...',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'backupBeforeUpdate',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Backup before update'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Updates'},\n fieldGroup: [\n {\n key: 'updateAutomatically',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install updates automatically'\n }\n }, {\n key: 'updateToPrereleases',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Install prereleases',\n advanced: true\n }\n },\n {\n key: 'deleteBackupsAfterWeeks',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Delete backups after...',\n addonRight: {\n text: 'weeks'\n },\n advanced: true\n }\n },\n {\n key: 'showUpdateBannerOnDocker',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show update banner when managed externally',\n advanced: true,\n help: 'If enabled a banner will be shown when new versions are available even when NZBHydra is run inside docker or is installed using a package manager (where you wouldn\\'t let NZBHydra update itself).'\n }\n },\n {\n key: 'showWhatsNewBanner',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show info banner after automatic updates',\n help: 'Please keep it enabled, I put some effort into the changelog ;-)',\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'History',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepHistory',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Keep history',\n help: 'Controls search and download history.',\n tooltip: 'If disabled no search or download history will be kept. These sections will be hidden in the GUI. You won\\'t be able to see stats. The database will still contain a short-lived history of transactions that are kept for 24 hours.'\n }\n },\n {\n key: 'keepHistoryForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep history for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep history (searches, downloads) for a certain time. Will decrease database size and may improve performance a bit. Rather reduce how long stats are kept.'\n }\n },\n {\n key: 'keepStatsForWeeks',\n type: 'horizontalInput',\n hideExpression: '!model.keepHistory',\n templateOptions: {\n type: 'number',\n label: 'Keep stats for...',\n addonRight: {\n text: 'weeks'\n },\n min: 1,\n help: 'Only keep stats for a certain time. Will decrease database size.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Database',\n tooltip: 'You should not change these values unless you\\'re either told to or really know what you\\'re doing.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'databaseCompactTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database compact time',\n addonRight: {\n text: 'ms'\n },\n min: 200,\n help: 'The time the database is given to compact (reduce size) when shutting down. Reduce this if shutting down NZBHydra takes too long (database size may increase). Takes effect on next restart.'\n }\n },\n {\n key: 'databaseRetentionTime',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database retention time',\n addonRight: {\n text: 'ms'\n },\n help: 'How long the db should retain old, persisted data. See here.'\n }\n },\n {\n key: 'databaseWriteDelay',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Database write delay',\n addonRight: {\n text: 'ms'\n },\n help: 'Maximum delay between a commit and flushing the log, in milliseconds. See here.'\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {label: 'Other'},\n fieldGroup: [\n {\n key: 'startupBrowser',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Open browser on startup'\n }\n },\n {\n key: 'showNews',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show news',\n help: \"Hydra will occasionally show news when opened. You can always find them in the system section\",\n advanced: true\n }\n },\n {\n key: 'proxyImages',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Proxy images',\n help: 'Download images from indexers and info providers (e.g. TMBD) and serve them via NZBHydra. Will only affect searches via UI, not API searches.'\n }\n },\n {\n key: 'checkOpenPort',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Check for open port',\n help: \"Check if NZBHydra is reachable from the internet and not protected\",\n advanced: true\n }\n },\n {\n key: 'xmx',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'JVM memory',\n addonRight: {\n text: 'MB'\n },\n min: 128,\n help: '256 should suffice except when working with big databases / many indexers. See wiki.',\n advanced: true\n }\n }\n ]\n\n }\n ],\n\n searching: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Indexer access',\n tooltip: 'Settings that control how communication with indexers is done and how to handle errors while doing that.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'timeout',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Timeout when accessing indexers',\n help: 'Any web call to an indexer taking longer than this is aborted.',\n min: 1,\n addonRight: {\n text: 'seconds'\n }\n }\n },\n {\n key: 'userAgent',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'User agent',\n help: 'Used when accessing indexers.',\n required: true,\n tooltip: 'Some indexers don\\'t seem to like Hydra and disable access based on the user agent. You can change it here if you want. Please leave it as it is if you have no problems. This allows indexers to gather better statistics on how their API services are used.',\n }\n },\n {\n key: 'userAgents',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Map user agents',\n help: 'Used to map the user agent from accessing services to the service names. Apply words with return key.',\n }\n },\n {\n key: 'ignoreLoadLimitingForInternalSearches',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore load limiting internally',\n help: 'When enabled load limiting defined for indexers will be ignored for internal searches.',\n }\n },\n {\n key: 'ignoreTemporarilyDisabled',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore temporary errors',\n tooltip: \"By default if access to an indexer fails the indexer is disabled for a certain amount of time (for a short while first, then increasingly longer if the problems persist). Disable this and always try these indexers.\",\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Category handling',\n tooltip: 'Settings that control the handling of newznab categories (e.g. 2000 for Movies).',\n advanced: true\n },\n fieldGroup: [\n\n {\n key: 'transformNewznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Transform newznab categories',\n help: 'Map newznab categories from API searches to configured categories and use all configured newznab categories in searches.'\n }\n },\n {\n key: 'sendTorznabCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send categories to trackers',\n help: 'If disabled no categories will be included in queries to torznab indexers (trackers).'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Media IDs / Query generation / Query processing',\n tooltip: 'Raw search engines like Binsearch don\\'t support searches based on IDs (e.g. for a movie using an IMDB id). You can enable query generation for these. Hydra will then try to retrieve the movie\\'s or show\\'s title and generate a query, for example \"showname s01e01\". In some cases an ID based search will not provide any results. You can enable a fallback so that in such a case the search will be repeated with a query using the title of the show or movie.'\n },\n fieldGroup: [\n {\n key: 'alwaysConvertIds',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Convert media IDs for...',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When enabled media ID conversions will always be done even when an indexer supports the already known ID(s).\",\n advanced: true\n }\n },\n {\n key: 'generateQueries',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Generate queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Generate queries for indexers which do not support ID based searches.\"\n }\n },\n {\n key: 'idFallbackToQueryGeneration',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Fallback to generated queries',\n options: [\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'All searches', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"When no results were found for a query ID search again using a generated query (on indexer level).\"\n }\n },\n {\n key: 'language',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'text',\n label: 'Language',\n required: true,\n help: 'Used for movie query generation and autocomplete only.',\n options: [{\"name\": \"Abkhaz\", value: \"ab\"}, {\n \"name\": \"Afar\",\n value: \"aa\"\n }, {\"name\": \"Afrikaans\", value: \"af\"}, {\"name\": \"Akan\", value: \"ak\"}, {\n \"name\": \"Albanian\",\n value: \"sq\"\n }, {\"name\": \"Amharic\", value: \"am\"}, {\n \"name\": \"Arabic\",\n value: \"ar\"\n }, {\"name\": \"Aragonese\", value: \"an\"}, {\"name\": \"Armenian\", value: \"hy\"}, {\n \"name\": \"Assamese\",\n value: \"as\"\n }, {\"name\": \"Avaric\", value: \"av\"}, {\"name\": \"Avestan\", value: \"ae\"}, {\n \"name\": \"Aymara\",\n value: \"ay\"\n }, {\"name\": \"Azerbaijani\", value: \"az\"}, {\n \"name\": \"Bambara\",\n value: \"bm\"\n }, {\"name\": \"Bashkir\", value: \"ba\"}, {\n \"name\": \"Basque\",\n value: \"eu\"\n }, {\"name\": \"Belarusian\", value: \"be\"}, {\"name\": \"Bengali\", value: \"bn\"}, {\n \"name\": \"Bihari\",\n value: \"bh\"\n }, {\"name\": \"Bislama\", value: \"bi\"}, {\n \"name\": \"Bosnian\",\n value: \"bs\"\n }, {\"name\": \"Breton\", value: \"br\"}, {\"name\": \"Bulgarian\", value: \"bg\"}, {\n \"name\": \"Burmese\",\n value: \"my\"\n }, {\"name\": \"Catalan\", value: \"ca\"}, {\n \"name\": \"Chamorro\",\n value: \"ch\"\n }, {\"name\": \"Chechen\", value: \"ce\"}, {\"name\": \"Chichewa\", value: \"ny\"}, {\n \"name\": \"Chinese\",\n value: \"zh\"\n }, {\"name\": \"Chuvash\", value: \"cv\"}, {\n \"name\": \"Cornish\",\n value: \"kw\"\n }, {\"name\": \"Corsican\", value: \"co\"}, {\"name\": \"Cree\", value: \"cr\"}, {\n \"name\": \"Croatian\",\n value: \"hr\"\n }, {\"name\": \"Czech\", value: \"cs\"}, {\"name\": \"Danish\", value: \"da\"}, {\n \"name\": \"Divehi\",\n value: \"dv\"\n }, {\"name\": \"Dutch\", value: \"nl\"}, {\n \"name\": \"Dzongkha\",\n value: \"dz\"\n }, {\"name\": \"English\", value: \"en\"}, {\n \"name\": \"Esperanto\",\n value: \"eo\"\n }, {\"name\": \"Estonian\", value: \"et\"}, {\"name\": \"Ewe\", value: \"ee\"}, {\n \"name\": \"Faroese\",\n value: \"fo\"\n }, {\"name\": \"Fijian\", value: \"fj\"}, {\"name\": \"Finnish\", value: \"fi\"}, {\n \"name\": \"French\",\n value: \"fr\"\n }, {\"name\": \"Fula\", value: \"ff\"}, {\n \"name\": \"Galician\",\n value: \"gl\"\n }, {\"name\": \"Georgian\", value: \"ka\"}, {\"name\": \"German\", value: \"de\"}, {\n \"name\": \"Greek\",\n value: \"el\"\n }, {\"name\": \"Guaraní\", value: \"gn\"}, {\n \"name\": \"Gujarati\",\n value: \"gu\"\n }, {\"name\": \"Haitian\", value: \"ht\"}, {\"name\": \"Hausa\", value: \"ha\"}, {\n \"name\": \"Hebrew\",\n value: \"he\"\n }, {\"name\": \"Herero\", value: \"hz\"}, {\n \"name\": \"Hindi\",\n value: \"hi\"\n }, {\"name\": \"Hiri Motu\", value: \"ho\"}, {\n \"name\": \"Hungarian\",\n value: \"hu\"\n }, {\"name\": \"Interlingua\", value: \"ia\"}, {\n \"name\": \"Indonesian\",\n value: \"id\"\n }, {\"name\": \"Interlingue\", value: \"ie\"}, {\n \"name\": \"Irish\",\n value: \"ga\"\n }, {\"name\": \"Igbo\", value: \"ig\"}, {\"name\": \"Inupiaq\", value: \"ik\"}, {\n \"name\": \"Ido\",\n value: \"io\"\n }, {\"name\": \"Icelandic\", value: \"is\"}, {\n \"name\": \"Italian\",\n value: \"it\"\n }, {\"name\": \"Inuktitut\", value: \"iu\"}, {\"name\": \"Japanese\", value: \"ja\"}, {\n \"name\": \"Javanese\",\n value: \"jv\"\n }, {\"name\": \"Kalaallisut\", value: \"kl\"}, {\n \"name\": \"Kannada\",\n value: \"kn\"\n }, {\"name\": \"Kanuri\", value: \"kr\"}, {\"name\": \"Kashmiri\", value: \"ks\"}, {\n \"name\": \"Kazakh\",\n value: \"kk\"\n }, {\"name\": \"Khmer\", value: \"km\"}, {\n \"name\": \"Kikuyu\",\n value: \"ki\"\n }, {\"name\": \"Kinyarwanda\", value: \"rw\"}, {\"name\": \"Kyrgyz\", value: \"ky\"}, {\n \"name\": \"Komi\",\n value: \"kv\"\n }, {\"name\": \"Kongo\", value: \"kg\"}, {\"name\": \"Korean\", value: \"ko\"}, {\n \"name\": \"Kurdish\",\n value: \"ku\"\n }, {\"name\": \"Kwanyama\", value: \"kj\"}, {\n \"name\": \"Latin\",\n value: \"la\"\n }, {\"name\": \"Luxembourgish\", value: \"lb\"}, {\n \"name\": \"Ganda\",\n value: \"lg\"\n }, {\"name\": \"Limburgish\", value: \"li\"}, {\"name\": \"Lingala\", value: \"ln\"}, {\n \"name\": \"Lao\",\n value: \"lo\"\n }, {\"name\": \"Lithuanian\", value: \"lt\"}, {\n \"name\": \"Luba-Katanga\",\n value: \"lu\"\n }, {\"name\": \"Latvian\", value: \"lv\"}, {\"name\": \"Manx\", value: \"gv\"}, {\n \"name\": \"Macedonian\",\n value: \"mk\"\n }, {\"name\": \"Malagasy\", value: \"mg\"}, {\n \"name\": \"Malay\",\n value: \"ms\"\n }, {\"name\": \"Malayalam\", value: \"ml\"}, {\"name\": \"Maltese\", value: \"mt\"}, {\n \"name\": \"Māori\",\n value: \"mi\"\n }, {\"name\": \"Marathi\", value: \"mr\"}, {\n \"name\": \"Marshallese\",\n value: \"mh\"\n }, {\"name\": \"Mongolian\", value: \"mn\"}, {\"name\": \"Nauru\", value: \"na\"}, {\n \"name\": \"Navajo\",\n value: \"nv\"\n }, {\"name\": \"Northern Ndebele\", value: \"nd\"}, {\n \"name\": \"Nepali\",\n value: \"ne\"\n }, {\"name\": \"Ndonga\", value: \"ng\"}, {\n \"name\": \"Norwegian Bokmål\",\n value: \"nb\"\n }, {\"name\": \"Norwegian Nynorsk\", value: \"nn\"}, {\n \"name\": \"Norwegian\",\n value: \"no\"\n }, {\"name\": \"Nuosu\", value: \"ii\"}, {\n \"name\": \"Southern Ndebele\",\n value: \"nr\"\n }, {\"name\": \"Occitan\", value: \"oc\"}, {\n \"name\": \"Ojibwe\",\n value: \"oj\"\n }, {\"name\": \"Old Church Slavonic\", value: \"cu\"}, {\"name\": \"Oromo\", value: \"om\"}, {\n \"name\": \"Oriya\",\n value: \"or\"\n }, {\"name\": \"Ossetian\", value: \"os\"}, {\"name\": \"Panjabi\", value: \"pa\"}, {\n \"name\": \"Pāli\",\n value: \"pi\"\n }, {\"name\": \"Persian\", value: \"fa\"}, {\n \"name\": \"Polish\",\n value: \"pl\"\n }, {\"name\": \"Pashto\", value: \"ps\"}, {\n \"name\": \"Portuguese\",\n value: \"pt\"\n }, {\"name\": \"Quechua\", value: \"qu\"}, {\"name\": \"Romansh\", value: \"rm\"}, {\n \"name\": \"Kirundi\",\n value: \"rn\"\n }, {\"name\": \"Romanian\", value: \"ro\"}, {\n \"name\": \"Russian\",\n value: \"ru\"\n }, {\"name\": \"Sanskrit\", value: \"sa\"}, {\"name\": \"Sardinian\", value: \"sc\"}, {\n \"name\": \"Sindhi\",\n value: \"sd\"\n }, {\"name\": \"Northern Sami\", value: \"se\"}, {\n \"name\": \"Samoan\",\n value: \"sm\"\n }, {\"name\": \"Sango\", value: \"sg\"}, {\"name\": \"Serbian\", value: \"sr\"}, {\n \"name\": \"Gaelic\",\n value: \"gd\"\n }, {\"name\": \"Shona\", value: \"sn\"}, {\"name\": \"Sinhala\", value: \"si\"}, {\n \"name\": \"Slovak\",\n value: \"sk\"\n }, {\"name\": \"Slovene\", value: \"sl\"}, {\n \"name\": \"Somali\",\n value: \"so\"\n }, {\"name\": \"Southern Sotho\", value: \"st\"}, {\n \"name\": \"Spanish\",\n value: \"es\"\n }, {\"name\": \"Sundanese\", value: \"su\"}, {\"name\": \"Swahili\", value: \"sw\"}, {\n \"name\": \"Swati\",\n value: \"ss\"\n }, {\"name\": \"Swedish\", value: \"sv\"}, {\"name\": \"Tamil\", value: \"ta\"}, {\n \"name\": \"Telugu\",\n value: \"te\"\n }, {\"name\": \"Tajik\", value: \"tg\"}, {\n \"name\": \"Thai\",\n value: \"th\"\n }, {\"name\": \"Tigrinya\", value: \"ti\"}, {\n \"name\": \"Tibetan Standard\",\n value: \"bo\"\n }, {\"name\": \"Turkmen\", value: \"tk\"}, {\"name\": \"Tagalog\", value: \"tl\"}, {\n \"name\": \"Tswana\",\n value: \"tn\"\n }, {\"name\": \"Tonga\", value: \"to\"}, {\"name\": \"Turkish\", value: \"tr\"}, {\n \"name\": \"Tsonga\",\n value: \"ts\"\n }, {\"name\": \"Tatar\", value: \"tt\"}, {\n \"name\": \"Twi\",\n value: \"tw\"\n }, {\"name\": \"Tahitian\", value: \"ty\"}, {\n \"name\": \"Uyghur\",\n value: \"ug\"\n }, {\"name\": \"Ukrainian\", value: \"uk\"}, {\"name\": \"Urdu\", value: \"ur\"}, {\n \"name\": \"Uzbek\",\n value: \"uz\"\n }, {\"name\": \"Venda\", value: \"ve\"}, {\n \"name\": \"Vietnamese\",\n value: \"vi\"\n }, {\"name\": \"Volapük\", value: \"vo\"}, {\"name\": \"Walloon\", value: \"wa\"}, {\n \"name\": \"Welsh\",\n value: \"cy\"\n }, {\"name\": \"Wolof\", value: \"wo\"}, {\n \"name\": \"Western Frisian\",\n value: \"fy\"\n }, {\"name\": \"Xhosa\", value: \"xh\"}, {\"name\": \"Yiddish\", value: \"yi\"}, {\n \"name\": \"Yoruba\",\n value: \"yo\"\n }, {\"name\": \"Zhuang\", value: \"za\"}, {\"name\": \"Zulu\", value: \"zu\"}]\n }\n },\n {\n key: 'replaceUmlauts',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Replace umlauts',\n help: 'Replace german umlauts and special characters (ä, ö, ü and ß) in external request queries.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result filters',\n tooltip: 'This section allows you to define global filters which will be applied to all search results. You can define words and regexes which must or must not be matched for a search result to be matched. You can also exclude certain usenet posters and groups which are known for spamming. You can define forbidden and required words for categories in the next tab (Categories). Usually required or forbidden words are applied on a word base, so they must form a complete word in a title. Only if they contain a dash or a dot they may appear anywhere in the title. Example: \"ea\" matches \"something.from.ea\" but not \"release.from.other\". \"web-dl\" matches \"title.web-dl\" and \"someweb-dl\".'\n },\n fieldGroup: [\n {\n key: 'applyRestrictions',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply word filters',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word/regex filters will be applied\"\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"Results with any of these words in the title will be ignored. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'One forbidden word in a result title dismisses the result.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Only results with titles that contain *all* words will be used. Title is converted to lowercase before. Apply words with return key.\",\n tooltip: 'If any of the required words is not found anywhere in a result title it\\'s also dismissed.'\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n\n {\n key: 'forbiddenGroups',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden groups',\n help: 'Posts from any groups containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.searching.applyRestrictions === \"NONE\";\n }\n },\n {\n key: 'forbiddenPosters',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden posters',\n help: 'Posts from any posters containing any of these words will be ignored. Apply words with return key.',\n advanced: true\n }\n },\n {\n key: 'languagesToKeep',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Languages to keep',\n help: 'If an indexer returns the language in the results only those results with configured languages will be used. Apply words with return key.'\n }\n },\n {\n key: 'maxAge',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Maximum results age',\n help: 'Results older than this are ignored. Can be overwritten per search. Apply words with return key.',\n addonRight: {\n text: 'days'\n }\n }\n },\n {\n key: 'minSeeders',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Minimum # seeders',\n help: 'Torznab results with fewer seeders will be ignored.'\n }\n },\n {\n key: 'ignorePassworded',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Ignore passworded releases',\n help: \"Not all indexers provide this information\",\n tooltip: 'Some indexers provide information if a release is passworded. If you select to ignore these releases only those will be ignored of which I know for sure that they\\'re actually passworded.'\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result processing'\n },\n fieldGroup: [\n {\n key: 'wrapApiErrors',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'text',\n label: 'Wrap API errors in empty results page',\n help: 'When enabled accessing tools will think the search was completed successfully but without results.',\n tooltip: 'In (hopefully) rare cases Hydra may crash when processing an API search request. You can enable to return an empty search page in these cases (if Hydra hasn\\'t crashed altogether ). This means that the calling tool (e.g. Sonarr) will think that the indexer (Hydra) is fine but just didn\\'t return a result. That way Hydra won\\'t be disabled as indexer but on the downside you may not be directly notified that an error occurred.',\n advanced: true\n }\n },\n {\n key: 'removeTrailing',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Remove trailing...',\n help: 'Removed from title if it ends with either of these. Case insensitive and disregards leading/trailing spaces. Allows wildcards (\"*\"). Apply words with return key.',\n tooltip: 'Hydra contains a predefined list of words which will be removed if a search result title ends with them. This allows better duplicate detection and cleans up the titles. Trailing words will be removed until none of the defined strings are found at the end of the result title.'\n }\n },\n {\n key: 'useOriginalCategories',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Use original categories',\n help: 'Enable to use the category descriptions provided by the indexer.',\n tooltip: 'Hydra attempts to parse the provided newznab category IDs for results and map them to the configured categories. In some cases this may lead to category names which are not quite correct. You can select to use the original category name used by the indexer. This will only affect which category name is shown in the results.',\n advanced: true\n }\n }\n ]\n },\n {\n type: 'repeatSection',\n key: 'customMappings',\n model: rootModel.searching,\n templateOptions: {\n tooltip: 'Here you can define mappings to modify either queries or titles for search requests or to dynamically change the titles of found results. The former allows you, for example, to change requests made by external tools, the latter to clean up results by indexers in a more advanced way.',\n btnText: 'Add new custom mapping',\n altLegendText: 'Mapping',\n headline: 'Custom mappings of queries, search titles and result titles',\n advanced: true,\n fields: [\n {\n key: 'affectedValue',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Affected value',\n options: [\n {name: 'Query', value: 'QUERY'},\n {name: 'Search title', value: 'TITLE'},\n {name: 'Result title', value: 'RESULT_TITLE'},\n ],\n required: true,\n help: \"Determines which value of the search request or result will be processed\"\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n hideExpression: 'model.affectedValue === \"RESULT_TITLE\"',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines in what context the mapping will be executed\"\n }\n },\n {\n key: 'matchAll',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Match whole string',\n help: 'If true then the input pattern must match the whole affected value. If false then any match will be replaced, even if it\\'s only part of the affected value.'\n }\n },\n {\n key: 'from',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Input pattern',\n help: 'Pattern which must match the query or title of a search request (completely or in part, depending on the previous setting). You may use regexes in groups which can be referenced in the output puttern by using {group:regex}. Case insensitive.',\n required: true\n }\n },\n {\n key: 'to',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Output pattern',\n required: true,\n 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). Use <remove> to remove the match.'\n }\n },\n {\n type: 'customMappingTest',\n }\n ],\n defaultModel: {\n searchType: null,\n affectedValue: null,\n matchAll: true,\n from: null,\n to: null\n }\n }\n },\n\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Result display'\n },\n fieldGroup: [\n {\n key: 'loadAllCachedOnInternal',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display all retrieved results',\n help: 'Load all results already retrieved from indexers. Might make sorting / filtering a bit slower. Will still be paged according to the limit set above.',\n advanced: true\n }\n },\n {\n key: 'loadLimitInternal',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Display...',\n addonRight: {\n text: 'results per page'\n },\n max: 500,\n required: true,\n help: 'Determines the number of results shown on one page. This might also cause more API hits because indexers are queried until the number of results is matched or all indexers are exhausted. Limit is 500.',\n advanced: true\n }\n },\n {\n key: 'coverSize',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cover width',\n addonRight: {\n text: 'px'\n },\n required: true,\n help: 'Determines width of covers in search results (when enabled in display options).'\n }\n }\n ]\n }, {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Quick filters'\n },\n fieldGroup: [\n {\n key: 'showQuickFilterButtons',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show quick filters',\n help: 'Show quick filter buttons for movie and TV results.'\n }\n },\n {\n key: 'alwaysShowQuickFilterButtons',\n type: 'horizontalSwitch',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'switch',\n label: 'Always show quick filters',\n help: 'Show all quick filter buttons for all types of searches.',\n advanced: true\n }\n },\n {\n key: 'customQuickFilterButtons',\n type: 'horizontalChips',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n type: 'text',\n label: 'Custom quick filters',\n help: 'Enter in the format DisplayName=Required1,Required2. Prefix words with ! to exclude them. Surround with / to mark as a regex. Apply values with enter key.',\n tooltip: 'E.g. use WEB=webdl,web-dl. for a quick filter with the name \"WEB\" to be displayed that searches for \"webdl\" and \"web-dl\" in lowercase search results.',\n advanced: true\n }\n },\n {\n key: 'preselectQuickFilterButtons',\n type: 'horizontalMultiselect',\n hideExpression: '!model.showQuickFilterButtons',\n templateOptions: {\n label: 'Preselect quickfilters',\n help: 'Choose which quickfilters will be selected by default.',\n options: [\n {id: 'source|camts', label: 'CAM / TS'},\n {id: 'source|tv', label: 'TV'},\n {id: 'source|web', label: 'WEB'},\n {id: 'source|dvd', label: 'DVD'},\n {id: 'source|bluray', label: 'Blu-Ray'},\n {id: 'quality|q480p', label: '480p'},\n {id: 'quality|q720p', label: '720p'},\n {id: 'quality|q1080p', label: '1080p'},\n {id: 'quality|q2160p', label: '2160p'},\n {id: 'other|q3d', label: '3D'},\n {id: 'other|qx265', label: 'x265'},\n {id: 'other|qhevc', label: 'HEVC'},\n ],\n optionsFunction: function (model) {\n var customQuickFilters = [];\n _.each(model.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n customQuickFilters.push({id: \"custom|\" + displayName, label: displayName})\n })\n return customQuickFilters;\n },\n tooltip: 'To select custom quickfilters you just entered please save the config first.',\n buttonText: \"None\",\n advanced: true\n }\n }\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Duplicate detection',\n tooltip: 'Hydra tries to find duplicate results from different indexers using heuristics. You can control the parameters for that but usually the default values work quite well.',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'duplicateSizeThresholdInPercent',\n type: 'horizontalPercentInput',\n templateOptions: {\n type: 'text',\n label: 'Duplicate size threshold',\n required: true,\n addonRight: {\n text: '%'\n }\n\n }\n },\n {\n key: 'duplicateAgeThreshold',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Duplicate age threshold',\n required: true,\n addonRight: {\n text: 'hours'\n }\n }\n }\n\n ]\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Other',\n advanced: true\n },\n fieldGroup: [\n {\n key: 'keepSearchResultsForDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Store results for ...',\n addonRight: {\n text: 'days'\n },\n required: true,\n tooltip: 'Found results are stored in the database for this long until they\\'re deleted. After that any links to Hydra results still stored elsewhere become invalid. You can increase the limit if you want, the disc space needed is negligible (about 75 MB for 7 days on my server).'\n }\n }, {\n key: 'historyForSearching',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Recet searches in search bar',\n required: true,\n tooltip: 'The number of recent searches shown in the search bar dropdown (the icon).'\n }\n },\n {\n key: 'globalCacheTimeMinutes',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Results cache time',\n help: 'When set search results will be cached for this time. Any search with the same parameters will return the cached results. API cache time parameters will be preferred. See wiki.',\n addonRight: {\n text: 'minutes'\n }\n }\n }\n ]\n }\n ],\n\n categoriesConfig: [\n {\n key: 'enableCategorySizes',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Category sizes',\n help: \"Preset min and max sizes depending on the selected category\",\n tooltip: 'Preset range of minimum and maximum sizes for its categories. When you select a category in the search area the appropriate fields are filled with these values.'\n }\n },\n {\n key: 'defaultCategory',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Default category',\n options: [],\n help: \"Set a default category. Reload page to set a category you just added.\"\n },\n controller: function ($scope) {\n var options = [];\n options.push({name: 'All', value: 'All'});\n _.each($scope.model.categories, function (cat) {\n options.push({name: cat.name, value: cat.name});\n });\n $scope.to.options = options;\n }\n },\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"The category configuration is not validated in any way. You can seriously fuck up Hydra's results and overall behavior so take care.\",\n \"Restrictions will taken from a result's category, not the search request category which may not always be the same.\"\n ],\n marginTop: '50px',\n advanced: true\n }\n },\n {\n type: 'repeatSection',\n key: 'categories',\n model: rootModel.categoriesConfig,\n templateOptions: {\n btnText: 'Add new category',\n headline: 'Categories',\n advanced: true,\n fields: [\n {\n key: 'name',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Name',\n help: 'Renaming categories might cause problems with repeating searches from the history.',\n required: true\n }\n },\n {\n key: 'searchType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Search type',\n options: [\n {name: 'General', value: 'SEARCH'},\n {name: 'Audio', value: 'MUSIC'},\n {name: 'EBook', value: 'BOOK'},\n {name: 'Movie', value: 'MOVIE'},\n {name: 'TV', value: 'TVSEARCH'}\n ],\n help: \"Determines how indexers will be searched and if autocompletion is available in the GUI\"\n }\n },\n {\n key: 'subtype',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Sub type',\n options: [\n {name: 'Anime', value: 'ANIME'},\n {name: 'Audiobook', value: 'AUDIOBOOK'},\n {name: 'Comic', value: 'COMIC'},\n {name: 'Ebook', value: 'EBOOK'},\n {name: 'None', value: 'NONE'}\n ],\n help: \"Special search type. Used for indexer specific mappings between categories and newznab IDs\"\n }\n },\n {\n key: 'applyRestrictionsType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Apply restrictions',\n options: [\n {name: 'All searches', value: 'BOTH'},\n {name: 'Internal searches', value: 'INTERNAL'},\n {name: 'API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"For which type of search word restrictions will be applied\"\n }\n },\n {\n key: 'requiredWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Required words',\n help: \"Must *all* be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'requiredRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Required regex',\n help: 'Must be present in a title (case is ignored).'\n }\n },\n {\n key: 'forbiddenWords',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Forbidden words',\n help: \"None may be present in a title which is converted to lowercase before. Apply words with return key.\"\n }\n },\n {\n key: 'forbiddenRegex',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Forbidden regex',\n help: 'Must not be present in a title (case is ignored).'\n }\n },\n {\n wrapper: 'settingWrapper',\n templateOptions: {\n label: 'Size preset',\n help: \"Will set these values on the search page\"\n },\n fieldGroup: [\n {\n key: 'minSizePreset',\n type: 'duoSetting',\n templateOptions: {\n addonRight: {\n text: 'MB'\n }\n\n }\n },\n {\n type: 'duolabel'\n },\n {\n key: 'maxSizePreset',\n type: 'duoSetting', templateOptions: {addonRight: {text: 'MB'}}\n }\n ]\n },\n {\n key: 'applySizeLimitsToApi',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Limit API results size',\n help: \"Enable to apply the size preset to API results from this category\"\n }\n },\n {\n key: 'newznabCategories',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Newznab categories',\n help: 'Map newznab categories to Hydra categories. Used for parsing and when searching internally. Apply categories with return key.',\n tooltip: 'Hydra tries to map API search (newnzab) categories to its internal list of categories, going from specific to general. Example: If an API search is done with a catagory that matches those of \"Movies HD\" the settings for that category are used. Otherwise it checks if it matches the \"Movies\" category and, if yes, uses that one. If that one doesn\\'t match no category settings are used.

                  ' +\n 'Related to that you must also define the newznab categories for every Hydra category, e.g. decide if the category for foreign movies (2010) is used for movie searches. This also controls the category mapping described above. You may combine newznab categories using \"&\" to require multiple numbers to be present in a result. For example \"2010&11000\" would require a search result to contain both 2010 and 11000 for that category to match.

                  ' +\n 'Note: When an API search defines categories the internal mapping is only used for the forbidden and required words. The search requests to your newznab indexers will still use the categories from the original request, not the ones configured here.'\n }\n },\n {\n key: 'ignoreResultsFrom',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Ignore results',\n options: [\n {name: 'For all searches', value: 'BOTH'},\n {name: 'For internal searches', value: 'INTERNAL'},\n {name: 'For API searches', value: 'API'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Ignore results from this category\",\n tooltip: 'If you want you can entirely ignore results from categories. Results from these categories will not show in the searches. If you select \"Internal\" or \"Always\" this category will also not be selectable on the search page.'\n }\n }\n\n ],\n defaultModel: {\n name: null,\n applySizeLimitsToApi: false,\n applyRestrictionsType: \"NONE\",\n forbiddenRegex: null,\n forbiddenWords: [],\n ignoreResultsFrom: \"NONE\",\n mayBeSelected: true,\n maxSizePreset: null,\n minSizePreset: null,\n newznabCategories: [],\n preselect: true,\n requiredRegex: null,\n requiredWords: [],\n searchType: \"SEARCH\",\n subtype: \"NONE\"\n }\n }\n }\n ],\n downloading: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'General',\n tooltip: 'Hydra allows sending NZB search results directly to downloaders (NZBGet, sabnzbd). Torrent downloaders are not supported.'\n },\n fieldGroup: [\n {\n key: 'saveTorrentsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'Torrent black hole',\n help: 'Allow torrents to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'saveNzbsTo',\n type: 'fileInput',\n templateOptions: {\n label: 'NZB black hole',\n help: 'Allow NZBs to be saved in this folder from the search results. Ignored if not set.',\n type: \"folder\"\n }\n },\n {\n key: 'nzbAccessType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'NZB access type',\n options: [\n {name: 'Proxy NZBs from indexer', value: 'PROXY'},\n {name: 'Redirect to the indexer', value: 'REDIRECT'}\n ],\n help: \"How access to NZBs is provided when NZBs are downloaded (by the user or external tools). Proxying is recommended as it allows fallback for failed downloads (see below)..\",\n tooltip: 'NZB downloads from Hydra can either be achieved by redirecting the requester to the original indexer or by downloading the NZB from the indexer and serving this. Redirecting has the advantage that it causes the least load on Hydra but also the disadvantage that the requester might be forwarded to an indexer link that contains the indexer\\'s API key. To prevent that select to proxy NZBs. It also allows fallback for failed downloads (next option).',\n advanced: true\n\n }\n },\n {\n key: 'externalUrl',\n type: 'horizontalInput',\n hideExpression: function ($viewValue, $modelValue, scope) {\n return !_.any(scope.model.downloaders, function (downloader) {\n return downloader.nzbAddingType === \"SEND_LINK\";\n });\n },\n templateOptions: {\n label: 'External URL',\n help: 'Used for links when sending links to the downloader.',\n tooltip: 'When using \"Add links\" to add NZBs to your downloader the links are usually calculated using the URL with which you accessed NZBHydra. This might be a URL that\\'s not accessible by the downloader (e.g. when it\\'s inside a docker container). Set the URL for NZBHydra that\\'s accessible by the downloader here and it will be used instead. ',\n advanced: true\n }\n },\n\n {\n key: 'fallbackForFailed',\n type: 'horizontalSelect',\n hideExpression: 'model.nzbAccessType === \"REDIRECT\"',\n templateOptions: {\n label: 'Fallback for failed downloads',\n options: [\n {name: 'GUI downloads', value: 'INTERNAL'},\n {name: 'API downloads', value: 'API'},\n {name: 'All downloads', value: 'BOTH'},\n {name: 'Never', value: 'NONE'}\n ],\n help: \"Fallback to similar results when a download fails. Only available when proxying NZBs (see above).\",\n tooltip: \"When you or an external program tries to download an NZB from NZBHydra the download may fail because the indexer is offline or its download limit has been reached. You can use this setting for NZBHydra to try and fall back on results from other indexers. It will search for results with the same name that were the result from the same search as where the download originated from. It will *not* execute another search.\"\n }\n },\n {\n key: 'sendMagnetLinks',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Send magnet links',\n help: \"Enable to send magnet links to the associated program on the server machine. Won't work with docker\"\n }\n },\n {\n key: 'updateStatuses',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Update statuses',\n help: \"Query your downloader for status updates of downloads\",\n advanced: true\n }\n },\n {\n key: 'showDownloaderStatus',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Show downloader footer',\n help: \"Show footer with downloader status\",\n advanced: true\n }\n },\n {\n key: 'primaryDownloader',\n type: 'horizontalSelect',\n hideExpression: 'model.downloaders.length <= 1 || !model.showDownloaderStatus',\n templateOptions: {\n label: 'Primary downloader',\n options: [],\n help: \"This downloader's state will be shown in the footer.\",\n tooltip: \"To select a downloader you just added please save the config first.\",\n optionsFunction: function (model) {\n var downloaders = [];\n _.each(model.downloaders, function (downloader) {\n downloaders.push({name: downloader.name, value: downloader.name})\n })\n return downloaders;\n },\n optionsFunctionAfter: function (model) {\n if (!model.primaryDownloader) {\n model.primaryDownloader = model.downloaders[0].name;\n }\n }\n }\n },\n ]\n },\n {\n wrapper: 'fieldset',\n key: 'downloaders',\n templateOptions: {label: 'Downloaders'},\n fieldGroup: [\n {\n type: \"downloaderConfig\",\n data: {}\n }\n ]\n }\n ],\n\n indexers: [\n {\n type: \"indexers\",\n data: {}\n },\n {\n type: 'recheckAllCaps'\n }\n ],\n auth: [\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main',\n\n },\n fieldGroup: [\n {\n key: 'authType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Auth type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'HTTP Basic auth', value: 'BASIC'},\n {name: 'Login form', value: 'FORM'}\n ],\n tooltip: '
                    ' +\n '
                  • With auth type \"None\" all areas are unrestricted.
                  • ' +\n '
                  • With auth type \"Form\" the basic page is loaded and login is done via a form.
                  • ' +\n '
                  • With auth type \"Basic\" you login via basic HTTP authentication. With all areas restricted this is the most secure as nearly no data is loaded from the server before you auth. Logging out is not supported with basic auth.
                  • ' +\n '
                  '\n }\n },\n {\n key: 'authHeader',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Auth header',\n help: 'Name of header that provides the username in requests from secure sources.',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'authHeaderIpRanges',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Secure IP ranges',\n help: 'IP ranges from which the auth header will be accepted. Apply with return key. Use values like \"192.168.0.1-192.168.0.100\" or single IP addresses like \"127.0.0.1\".',\n advanced: true\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\" || _.isNullOrEmpty(rootModel.auth.authHeader);\n }\n },\n {\n key: 'rememberUsers',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Remember users',\n help: 'Remember users with cookie for 14 days.'\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n }\n },\n {\n key: 'rememberMeValidityDays',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Cookie expiry',\n help: 'How long users are remembered.',\n addonRight: {\n text: 'days'\n },\n advanced: true\n }\n }\n\n ]\n },\n\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Restrictions',\n tooltip: 'Select which areas/features can only be accessed by logged in users (i.e. are restricted). If you don\\'t to allow anonymous users to do anything just leave everything selected.
                  You can decide for every user if he is allowed to:
                  ' +\n '
                    \\n' +\n '
                  • view the search page at all
                  • \\n' +\n '
                  • view the stats
                  • \\n' +\n '
                  • access the admin area (config and control)
                  • \\n' +\n '
                  • view links for downloading NZBs and see their details
                  • \\n' +\n '
                  • may select which indexers are used for search.
                  • \\n' +\n '
                  '\n },\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n fieldGroup: [\n {\n key: 'restrictSearch',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict searching',\n help: 'Restrict access to searching.'\n }\n },\n {\n key: 'restrictStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict stats',\n help: 'Restrict access to stats.'\n }\n },\n {\n key: 'restrictAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict admin',\n help: 'Restrict access to admin functions.'\n }\n },\n {\n key: 'restrictDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict NZB details & DL',\n help: 'Restrict NZB details, comments and download links.'\n }\n },\n {\n key: 'restrictIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Restrict indexer selection box',\n help: 'Restrict visibility of indexer selection box in search. Affects only GUI.'\n }\n },\n {\n key: 'allowApiStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Allow stats access',\n help: 'Allow access to stats via external API.'\n }\n }\n ]\n },\n\n {\n type: 'repeatSection',\n key: 'users',\n model: rootModel.auth,\n hideExpression: function () {\n return rootModel.auth.authType === \"NONE\";\n },\n templateOptions: {\n btnText: 'Add new user',\n altLegendText: 'Authless',\n headline: 'Users',\n fields: [\n {\n key: 'username',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Username',\n required: true\n }\n },\n {\n key: 'password',\n type: 'passwordSwitch',\n templateOptions: {\n type: 'password',\n label: 'Password',\n required: true\n }\n },\n {\n key: 'maySeeAdmin',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see admin area'\n }\n },\n {\n key: 'maySeeStats',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see stats'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'maySeeDetailsDl',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see NZB details & DL links'\n },\n hideExpression: 'model.maySeeAdmin'\n },\n {\n key: 'showIndexerSelection',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'May see indexer selection box'\n },\n hideExpression: 'model.maySeeAdmin'\n }\n ],\n defaultModel: {\n username: null,\n password: null,\n token: null,\n maySeeStats: true,\n maySeeAdmin: true,\n maySeeDetailsDl: true,\n showIndexerSelection: true\n }\n }\n }\n ],\n notificationConfig: [\n {\n type: 'help',\n templateOptions: {\n type: 'help',\n lines: [\n \"NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.\",\n 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.',\n \"NZBHydra will also show notifications on the GUI if enabled.\",\n \"Only URLs in the form of the http://../notify/ form will work. Each notification requires a non-null value for URL to be enabled, but always uses the Main URL.\"\n ]\n }\n },\n {\n wrapper: 'fieldset',\n templateOptions: {\n label: 'Main'\n },\n fieldGroup: [\n\n {\n key: 'appriseType',\n type: 'horizontalSelect',\n templateOptions: {\n type: 'select',\n label: 'Apprise type',\n options: [\n {name: 'None', value: 'NONE'},\n {name: 'API', value: 'API'},\n {name: 'CLI', value: 'CLI'}\n ]\n }\n },\n {\n key: 'appriseApiUrl',\n type: 'horizontalInput',\n templateOptions: {\n type: 'string',\n label: 'Apprise API URL',\n help: 'URL of Apprise API to send notifications to.'\n },\n hideExpression: 'model.appriseType !== \"API\"'\n },\n {\n key: 'appriseCliPath',\n type: 'fileInput',\n templateOptions: {\n type: 'file',\n label: 'Apprise runnable',\n help: 'Full path of of Apprise runnable to execute.'\n },\n hideExpression: 'model.appriseType !== \"CLI\"'\n },\n {\n key: 'displayNotifications',\n type: 'horizontalSwitch',\n templateOptions: {\n type: 'switch',\n label: 'Display notifications',\n help: 'If enabled notifications will be shown on the GUI.'\n }\n },\n {\n key: 'displayNotificationsMax',\n type: 'horizontalInput',\n templateOptions: {\n type: 'number',\n label: 'Show max notifications',\n help: 'Max number of notifications to show on the GUI. If more have piled up a notification will indicate this and link to the notification history.'\n },\n hideExpression: '!model.displayNotifications'\n },\n {\n key: 'filterOuts',\n type: 'horizontalChips',\n templateOptions: {\n type: 'text',\n label: 'Hide if message contains...',\n help: 'Apply values with return key. Surround with \"/\" for regex (e.g. /contains[0-9]This/). Case insensitive.',\n\n },\n hideExpression: '!model.displayNotifications'\n }\n ]\n },\n\n {\n type: 'notificationSection',\n key: 'entries',\n model: rootModel.notificationConfig,\n templateOptions: {\n btnText: 'Add new notification',\n altLegendText: 'Notification',\n headline: 'Notifications',\n fields: [\n {\n key: 'appriseUrls',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'URLs',\n help: 'One or more URLs identifying where the notification should be sent to, comma-separated.'\n }\n },\n {\n key: 'titleTemplate',\n type: 'horizontalInput',\n templateOptions: {\n type: 'text',\n label: 'Title template'\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTextArea',\n templateOptions: {\n type: 'text',\n label: 'Body template',\n required: true\n },\n controller: notificationTemplateHelpController\n },\n {\n key: 'messageType',\n type: 'horizontalSelect',\n templateOptions: {\n label: 'Message type',\n options: [\n {name: 'Info', value: 'INFO'},\n {name: 'Success', value: 'SUCCESS'},\n {name: 'Warning', value: 'WARNING'},\n {name: 'Failure', value: 'FAILURE'}\n ],\n help: \"Select the message type to use.\"\n }\n },\n {\n key: 'bodyTemplate',\n type: 'horizontalTestNotification'\n }\n\n ],\n defaultModel: {\n eventType: null,\n appriseUrls: null,\n titleTemplate: null,\n bodyTemplate: null,\n messageType: 'WARNING'\n }\n }\n }\n ]\n\n }\n\n function notificationTemplateHelpController($scope, NotificationService) {\n $scope.model.eventTypeReadable = NotificationService.humanize($scope.model.eventType);\n $scope.to.help = NotificationService.getTemplateHelp($scope.model.eventType);\n }\n }\n}\n\nfunction handleConnectionCheckFail(ModalService, data, model, whatFailed, deferred) {\n var message;\n var yesText;\n if (data.checked) {\n message = \"The connection to the \" + whatFailed + \" failed: \" + data.message + \"
                  Do you want to add it anyway?\";\n yesText = \"I know what I'm doing\";\n } else {\n message = \"The connection to the \" + whatFailed + \" could not be tested, sorry. Please check the log.\";\n yesText = \"I'll risk it\";\n }\n ModalService.open(\"Connection check failed\", message, {\n yes: {\n onYes: function () {\n deferred.resolve();\n },\n text: yesText\n },\n no: {\n onNo: function () {\n model.enabled = false;\n deferred.resolve();\n },\n text: \"Add it, but disabled\"\n },\n cancel: {\n onCancel: function () {\n deferred.reject();\n },\n text: \"Aahh, let me try again\"\n }\n });\n}\n","\nConfigController.$inject = [\"$scope\", \"$http\", \"activeTab\", \"ConfigService\", \"config\", \"DownloaderCategoriesService\", \"ConfigFields\", \"ConfigModel\", \"ModalService\", \"RestartService\", \"localStorageService\", \"$state\", \"growl\", \"$window\"];angular\n .module('nzbhydraApp')\n .factory('ConfigModel', function () {\n return {};\n });\n\nangular\n .module('nzbhydraApp')\n .factory('ConfigWatcher', function () {\n var $scope;\n\n return {\n watch: watch\n };\n\n function watch(scope) {\n $scope = scope;\n $scope.$watchGroup([\"config.main.host\"], function () {\n }, true);\n }\n });\n\n\nangular\n .module('nzbhydraApp')\n .controller('ConfigController', ConfigController);\n\nfunction ConfigController($scope, $http, activeTab, ConfigService, config, DownloaderCategoriesService, ConfigFields, ConfigModel, ModalService, RestartService, localStorageService, $state, growl, $window) {\n $scope.config = config;\n $scope.submit = submit;\n $scope.activeTab = activeTab;\n\n $scope.restartRequired = false;\n $scope.ignoreSaveNeeded = false;\n console.log(localStorageService.get(\"showAdvanced\"));\n if (localStorageService.get(\"showAdvanced\") === null) {\n $scope.showAdvanced = false;\n localStorageService.set(\"showAdvanced\", false);\n } else {\n $scope.showAdvanced = localStorageService.get(\"showAdvanced\");\n }\n\n\n $scope.toggleShowAdvanced = function () {\n $scope.showAdvanced = !$scope.showAdvanced;\n var wasDirty = $scope.form.$dirty === true;\n\n $scope.allTabs[$scope.activeTab].model.showAdvanced = $scope.showAdvanced === true;\n //Also save in main tab where it will be stored to file\n $scope.allTabs[0].model.showAdvanced = $scope.allTabs[$scope.activeTab].model.showAdvanced === true;\n $scope.form.$dirty = wasDirty;\n localStorageService.set(\"showAdvanced\", $scope.showAdvanced);\n }\n\n function updateAndAskForRestartIfNecessary(responseData) {\n if (angular.isUndefined($scope.form)) {\n console.error(\"Unable to determine if a restart is necessary\");\n return;\n }\n\n $scope.form.$setPristine();\n DownloaderCategoriesService.invalidate();\n if ($scope.restartRequired) {\n ModalService.open(\"Restart required\", \"The changes you have made may require a restart to be effective.
                  Do you want to restart now?\", {\n yes: {\n onYes: function () {\n RestartService.restart();\n }\n },\n no: {\n onNo: function ($uibModalInstance) {\n //Needs to be clicked twice for some reason\n $scope.restartRequired = false;\n $uibModalInstance.dismiss();\n $uibModalInstance.dismiss();\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n });\n } else {\n $scope.config = responseData.newConfig;\n $window.location.reload();\n }\n }\n\n function handleConfigSetResponse(response, ignoreWarnings, restartNeeded) {\n if (angular.isUndefined(ignoreWarnings)) {\n ignoreWarnings = localStorageService.get(\"ignoreWarnings\") !== null ? localStorageService.get(\"ignoreWarnings\") : false;\n }\n //Communication with server was successful but there might be validation errors and/or warnings\n var warningMessages = response.data.warningMessages;\n var errorMessages = response.data.errorMessages;\n $scope.restartRequired = response.data.restartNeeded || (angular.isDefined(restartNeeded) ? restartNeeded : false);\n var showMessage = errorMessages.length > 0 || (warningMessages.length > 0 && !ignoreWarnings);\n\n function extendMessageWithList(message, messages) {\n _.forEach(messages, function (x) {\n message += \"
                • \" + x + \"
                • \";\n });\n message += \"
                \";\n return message;\n }\n\n if (showMessage) {\n var options;\n var message;\n var title;\n if (errorMessages.length > 0) { //Actual errors which cannot be ignored\n title = \"Config validation failed\";\n message = 'The following errors have been found in your config. They need to be fixed.
                  ';\n message = extendMessageWithList(message, response.data.errorMessages);\n if (warningMessages.length > 0) {\n message += '
                  The following warnings were found. You can ignore them if you wish.
                    ';\n message = extendMessageWithList(message, response.data.warningMessages);\n }\n options = {\n yes: {\n onYes: function () {\n },\n text: \"OK\"\n }\n };\n } else if (warningMessages.length > 0) {\n title = \"Config validation warnings\";\n message = '
                    The following warnings have been found. You can ignore them if you wish. The config was already saved.
                      ';\n message = extendMessageWithList(message, response.data.warningMessages);\n options = {\n // cancel: {\n // onCancel: function () {\n // $scope.form.$setPristine();\n // localStorageService.set(\"ignoreWarnings\", true);\n // ConfigService.set($scope.config, true).then(function (response) {\n // handleConfigSetResponse(response, true, $scope.restartRequired);\n // updateAndAskForRestartIfNecessary(response.data);\n // }, function (response) {\n // //Actual error while setting or validating config\n // growl.error(response.data);\n // });\n // },\n // text: \"OK, don't show warnings again\"\n // },\n yes: {\n onYes: function () {\n handleConfigSetResponse(response, true, $scope.restartRequired);\n updateAndAskForRestartIfNecessary(response.data);\n },\n text: \"OK\"\n }\n };\n }\n ModalService.open(title, message, options, \"md\", \"left\");\n } else {\n updateAndAskForRestartIfNecessary(response.data);\n }\n }\n\n function submit() {\n if ($scope.form.$valid && !$scope.myShowError) {\n ConfigService.set($scope.config, true).then(function (response) {\n handleConfigSetResponse(response);\n }, function (response) {\n //Actual error while setting or validating config\n growl.error(response.data);\n });\n\n } else {\n growl.error(\"Config invalid. Please check your settings.\");\n\n //Ridiculously hacky way to make the error messages appear\n try {\n if (angular.isDefined(form.$error.required)) {\n _.each(form.$error.required, function (item) {\n if (angular.isDefined(item.$error.required)) {\n _.each(item.$error.required, function (item2) {\n item2.$setTouched();\n });\n }\n });\n }\n angular.forEach($scope.form.$error.required, function (field) {\n field.$setTouched();\n });\n } catch (err) {\n //\n }\n\n }\n }\n\n ConfigModel = config;\n\n $scope.fields = ConfigFields.getFields($scope.config);\n\n $scope.allTabs = [\n {\n active: false,\n state: 'root.config.main',\n name: 'Main',\n model: ConfigModel.main,\n fields: $scope.fields.main\n },\n {\n active: false,\n state: 'root.config.auth',\n name: 'Authorization',\n model: ConfigModel.auth,\n fields: $scope.fields.auth,\n options: {}\n },\n {\n active: false,\n state: 'root.config.searching',\n name: 'Searching',\n model: ConfigModel.searching,\n fields: $scope.fields.searching,\n options: {}\n },\n {\n active: false,\n state: 'root.config.categories',\n name: 'Categories',\n model: ConfigModel.categoriesConfig,\n fields: $scope.fields.categoriesConfig,\n options: {}\n },\n {\n active: false,\n state: 'root.config.downloading',\n name: 'Downloading',\n model: ConfigModel.downloading,\n fields: $scope.fields.downloading,\n options: {}\n },\n {\n active: false,\n state: 'root.config.indexers',\n name: 'Indexers',\n model: ConfigModel.indexers,\n fields: $scope.fields.indexers,\n options: {}\n },\n {\n active: false,\n state: 'root.config.notifications',\n name: 'Notifications',\n model: ConfigModel.notificationConfig,\n fields: $scope.fields.notificationConfig,\n options: {}\n }\n ];\n\n //Copy showAdvanced setting over from main tab's setting\n _.each($scope.allTabs, function (tab) {\n tab.model.showAdvanced = $scope.showAdvanced === true;\n })\n\n $scope.isSavingNeeded = function () {\n return $scope.form.$dirty && $scope.form.$valid && !$scope.ignoreSaveNeeded;\n };\n\n $scope.goToConfigState = function (index) {\n $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true});\n };\n\n $scope.apiHelp = function () {\n\n if ($scope.isSavingNeeded()) {\n growl.info(\"Please save first\");\n return;\n }\n var apiHelp = ConfigService.apiHelp().then(function (data) {\n\n var html = '' +\n '' +\n '' +\n '' +\n '
                      Newznab API endpoint:%newznab%
                      Torznab API endpoint:%torznab%
                      API key:%apikey%
                      ';\n //Torznab API endpoint: %torznab%
                      API key: %apikey%\n html = html.replace(\"%newznab%\", data.newznabApi);\n html = html.replace(\"%torznab%\", data.torznabApi);\n html = html.replace(\"%apikey%\", data.apiKey);\n ModalService.open(\"API infos\", html, {}, \"md\");\n });\n };\n\n $scope.configureIn = function (externalTool) {\n\n if ($scope.isSavingNeeded()) {\n growl.info(\"Please save first\");\n return;\n }\n ConfigService.configureIn(externalTool);\n };\n\n $scope.$on('$stateChangeStart',\n function (event, toState, toParams, fromState, fromParams) {\n if ($scope.isSavingNeeded()) {\n event.preventDefault();\n ModalService.open(\"Unsaved changed\", \"Do you want to save before leaving?\", {\n yes: {\n onYes: function () {\n $scope.submit();\n $state.go(toState);\n },\n text: \"Yes\"\n },\n no: {\n onNo: function () {\n $scope.ignoreSaveNeeded = true;\n $scope.allTabs[$scope.activeTab].options.resetModel();\n $state.go(toState);\n },\n text: \"No\"\n },\n cancel: {\n onCancel: function () {\n event.preventDefault();\n },\n text: \"Cancel\"\n }\n });\n }\n });\n\n $scope.$watch(\"$scope.form.$valid\", function () {\n });\n\n $scope.$on('$formValidity', function (event, isValid) {\n console.log(\"Received $formValidity event: \" + isValid);\n $scope.form.$valid = isValid;\n $scope.form.$invalid = !isValid;\n $scope.showError = !isValid;\n $scope.myShowError = !isValid;\n });\n}\n\n\n","\nUpdateService.$inject = [\"$http\", \"growl\", \"blockUI\", \"RestartService\", \"RequestsErrorHandler\", \"$uibModal\", \"$timeout\"];\nUpdateModalInstanceCtrl.$inject = [\"$scope\", \"$http\", \"$interval\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .factory('UpdateService', UpdateService);\n\nfunction UpdateService($http, growl, blockUI, RestartService, RequestsErrorHandler, $uibModal, $timeout) {\n\n var currentVersion;\n var latestVersion;\n var betaVersion;\n var updateAvailable;\n var betaUpdateAvailable;\n var latestVersionIgnored;\n var betaVersionsEnabled;\n var versionHistory;\n var updatedExternally;\n var automaticUpdateToNotice;\n\n\n return {\n update: update,\n showChanges: showChanges,\n getInfos: getInfos,\n getVersionHistory: getVersionHistory,\n ignore: ignore,\n showChangesFromAutomaticUpdate: showChangesFromAutomaticUpdate\n };\n\n function getInfos() {\n return RequestsErrorHandler.specificallyHandled(function () {\n return $http.get(\"internalapi/updates/infos\").then(\n function (response) {\n currentVersion = response.data.currentVersion;\n latestVersion = response.data.latestVersion;\n betaVersion = response.data.betaVersion;\n updateAvailable = response.data.updateAvailable;\n betaUpdateAvailable = response.data.betaUpdateAvailable;\n latestVersionIgnored = response.data.latestVersionIgnored;\n betaVersionsEnabled = response.data.betaVersionsEnabled;\n updatedExternally = response.data.updatedExternally;\n automaticUpdateToNotice = response.data.automaticUpdateToNotice;\n return response;\n }, function () {\n\n }\n );\n });\n }\n\n function ignore(version) {\n return $http.put(\"internalapi/updates/ignore/\" + version).then(function (response) {\n return response;\n });\n }\n\n function getVersionHistory() {\n return $http.get(\"internalapi/updates/versionHistory\").then(function (response) {\n versionHistory = response.data;\n return response;\n });\n }\n\n function showChanges(version) {\n return $http.get(\"internalapi/updates/changesSince/\" + version).then(function (response) {\n var params = {\n size: \"lg\",\n templateUrl: \"static/html/changelog-modal.html\",\n resolve: {\n versionHistory: function () {\n return response.data;\n }\n },\n controller: function ($scope, $sce, $uibModalInstance, versionHistory) {\n $scope.versionHistory = versionHistory;\n\n $scope.ok = function () {\n $uibModalInstance.dismiss();\n };\n }\n };\n\n var modalInstance = $uibModal.open(params);\n modalInstance.result.then();\n });\n }\n\n function showChangesFromAutomaticUpdate() {\n return $http.get(\"internalapi/updates/automaticUpdateVersionHistory\").then(function (response) {\n var params = {\n size: \"lg\",\n templateUrl: \"static/html/changelog-modal.html\",\n resolve: {\n versionHistory: function () {\n return response.data;\n }\n },\n controller: function ($scope, $sce, $uibModalInstance, versionHistory) {\n $scope.versionHistory = versionHistory;\n\n $scope.ok = function () {\n $uibModalInstance.dismiss();\n };\n }\n };\n\n var modalInstance = $uibModal.open(params);\n modalInstance.result.then();\n return $http.get(\"internalapi/updates/ackAutomaticUpdateVersionHistory\").then(function (response) {\n\n });\n });\n }\n\n\n function update(version) {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/update-modal.html',\n controller: 'UpdateModalInstanceCtrl',\n size: \"md\",\n backdrop: 'static',\n keyboard: false\n });\n $http.put(\"internalapi/updates/installUpdate/\" + version).then(function () {\n //Handle like restart, ping application and wait\n //Perhaps save the version to which we want to update, ask later and see if they're equal. If not updating apparently failed...\n $timeout(function () {\n //Give user some time to read the last message\n RestartService.startCountdown(\"\");\n modalInstance.close();\n }, 2000);\n },\n function () {\n growl.info(\"An error occurred while updating. Please check the logs.\");\n modalInstance.close();\n });\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('UpdateModalInstanceCtrl', UpdateModalInstanceCtrl);\n\nfunction UpdateModalInstanceCtrl($scope, $http, $interval, RequestsErrorHandler) {\n $scope.messages = [];\n\n var interval = $interval(function () {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/updates/messages\").then(\n function (data) {\n $scope.messages = data.data;\n }\n );\n });\n },\n 200);\n\n $scope.$on('$destroy', function () {\n if (interval !== null) {\n $interval.cancel(interval);\n }\n });\n\n}\n","\nSystemController.$inject = [\"$scope\", \"$state\", \"activeTab\", \"simpleInfos\", \"$http\", \"growl\", \"RestartService\", \"MigrationService\", \"ConfigService\", \"NzbHydraControlService\", \"RequestsErrorHandler\"];angular\n .module('nzbhydraApp')\n .controller('SystemController', SystemController);\n\nfunction SystemController($scope, $state, activeTab, simpleInfos, $http, growl, RestartService, MigrationService, ConfigService, NzbHydraControlService, RequestsErrorHandler) {\n\n $scope.activeTab = activeTab;\n $scope.foo = {\n csv: \"\",\n sql: \"\"\n };\n\n $scope.simpleInfos = simpleInfos;\n\n $scope.shutdown = function () {\n NzbHydraControlService.shutdown().then(function () {\n growl.info(\"Shutdown initiated. Cya!\");\n },\n function () {\n growl.info(\"Unable to send shutdown command.\");\n })\n };\n\n $scope.restart = function () {\n RestartService.restart();\n };\n\n $scope.reloadConfig = function () {\n ConfigService.reloadConfig().then(function () {\n growl.info(\"Successfully reloaded config. Some setting may need a restart to take effect.\")\n }, function (data) {\n growl.error(data.message);\n })\n };\n\n\n $scope.migrate = function () {\n MigrationService.migrate();\n };\n\n\n $scope.allTabs = [\n {\n active: false,\n state: 'root.system.control',\n name: \"Control\"\n },\n {\n active: false,\n state: 'root.system.updates',\n name: \"Updates\"\n },\n {\n active: false,\n state: 'root.system.log',\n name: \"Log\"\n },\n {\n active: false,\n state: 'root.system.tasks',\n name: \"Tasks\"\n },\n {\n active: false,\n state: 'root.system.backup',\n name: \"Backup\"\n },\n {\n active: false,\n state: 'root.system.bugreport',\n name: \"Bugreport / Debug\"\n },\n {\n active: false,\n state: 'root.system.news',\n name: \"News\"\n },\n {\n active: false,\n state: 'root.system.about',\n name: \"About\"\n }\n ];\n\n\n $scope.goToSystemState = function (index) {\n $state.go($scope.allTabs[index].state, {activeTab: index}, {inherit: false, notify: true, reload: true});\n };\n\n $scope.downloadDebuggingInfos = function () {\n $scope.isBackupCreationAction = true;\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/createAndProvideZipAsBytes',\n responseType: 'arraybuffer'\n }).then(function (response, status, headers, config) {\n var a = document.createElement('a');\n var blob = new Blob([response.data], {'type': \"application/octet-stream\"});\n a.href = URL.createObjectURL(blob);\n a.download = \"nzbhydra-debuginfos-\" + moment().format(\"YYYY-MM-DD-HH-mm\") + \".zip\";\n\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n $scope.isBackupCreationAction = false;\n });\n };\n\n\n $scope.uploadDebuggingInfos = function () {\n $scope.isBackupCreationAction = true;\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/createAndUploadDebugInfos'\n }).then(function (response) {\n $scope.debugInfosUrl = 'URL with debug infos (will auto-delete on first download=: ' + response.data + '';\n $scope.isBackupCreationAction = false;\n }, function (response) {\n $scope.debugInfosUrl = response.data;\n $scope.isBackupCreationAction = false;\n });\n };\n\n $scope.logThreadDump = function () {\n $http({\n method: 'GET',\n url: 'internalapi/debuginfos/logThreadDump'\n });\n };\n\n $scope.executeSqlQuery = function () {\n $http.post('internalapi/debuginfos/executesqlquery', $scope.foo.sql).then(function (response) {\n if (response.data.successful) {\n $scope.foo.csv = response.data.message;\n } else {\n growl.error(response.data.message);\n }\n });\n };\n\n $scope.executeSqlUpdate = function () {\n $http.post('internalapi/debuginfos/executesqlupdate', $scope.foo.sql).then(function (response) {\n if (response.data.successful) {\n $scope.foo.csv = response.data.message + \" rows affected\";\n } else {\n growl.error(response.data.message);\n }\n });\n };\n\n\n $scope.cpuChart = {\n options: {\n chart:\n {\n type: 'lineChart',\n height: 450,\n margin: {\n top: 20,\n right: 20,\n bottom: 60,\n left: 65\n },\n x: function (d) {\n return d.time;\n },\n y: function (d) {\n return d.value;\n },\n xAxis: {\n axisLabel: 'Time',\n tickFormat: function (d) {\n return moment.unix(d).local().format(\"HH:mm:ss\");\n },\n showMaxMin: true\n },\n\n yAxis: {\n axisLabel: 'CPU %'\n },\n interactive: true\n }\n },\n data: []\n };\n\n function update() {\n RequestsErrorHandler.specificallyHandled(function () {\n $http.get(\"internalapi/debuginfos/threadCpuUsage\", {ignoreLoadingBar: true}).then(function (response) {\n try {\n if (!response) {\n console.error(\"No CPU usage data from server\");\n return;\n }\n $scope.cpuChart.data = response.data;\n\n } catch (e) {\n console.error(e);\n clearInterval(timer);\n }\n },\n function () {\n console.error(\"Error while loading CPU usage data status\");\n clearInterval(timer);\n }\n );\n });\n }\n\n $scope.cpuChart.data = [];\n\n update();\n var timer = setInterval(function () {\n update();\n }, 5000);\n\n $scope.$on('$destroy', function () {\n if (timer !== null) {\n clearInterval(timer);\n }\n });\n\n}\n","\r\nStatsService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('StatsService', StatsService);\r\n\r\nfunction StatsService($http) {\r\n\r\n return {\r\n get: getStats,\r\n getDownloadHistory: getDownloadHistory,\r\n getNotificationHistory: getNotificationHistory\r\n };\r\n\r\n function getStats(after, before, includeDisabled, switchState) {\r\n var requestBody = {after: after, before: before, includeDisabled: includeDisabled};\r\n requestBody = _.extend(requestBody, switchState);\r\n return $http.post(\"internalapi/stats\", requestBody).then(function (response) {\r\n return response.data;\r\n });\r\n }\r\n\r\n function buildParams(pageNumber, limit, filterModel, sortModel) {\r\n var params = {page: pageNumber, limit: limit, filterModel: filterModel};\r\n if (angular.isUndefined(pageNumber)) {\r\n params.page = 1;\r\n }\r\n if (angular.isUndefined(limit)) {\r\n params.limit = 100;\r\n }\r\n if (angular.isUndefined(filterModel)) {\r\n params.filterModel = {}\r\n }\r\n if (!angular.isUndefined(sortModel)) {\r\n params.sortModel = sortModel;\r\n } else {\r\n params.sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n }\r\n return params;\r\n }\r\n\r\n function getDownloadHistory(pageNumber, limit, filterModel, sortModel) {\r\n var params = buildParams(pageNumber, limit, filterModel, sortModel);\r\n return $http.post(\"internalapi/history/downloads\", params).then(function (response) {\r\n return {\r\n nzbDownloads: response.data.content,\r\n totalDownloads: response.data.totalElements\r\n };\r\n\r\n });\r\n }\r\n\r\n function getNotificationHistory(pageNumber, limit, filterModel, sortModel) {\r\n var params = buildParams(pageNumber, limit, filterModel, sortModel);\r\n return $http.post(\"internalapi/history/notifications\", params).then(function (response) {\r\n return {\r\n notifications: response.data.content,\r\n totalNotifications: response.data.totalElements\r\n };\r\n\r\n });\r\n }\r\n\r\n}","\r\nStatsController.$inject = [\"$scope\", \"$filter\", \"StatsService\", \"blockUI\", \"localStorageService\", \"$timeout\", \"$window\", \"ConfigService\"];angular\r\n .module('nzbhydraApp')\r\n .controller('StatsController', StatsController);\r\n\r\nfunction StatsController($scope, $filter, StatsService, blockUI, localStorageService, $timeout, $window, ConfigService) {\r\n\r\n $scope.dateOptions = {\r\n dateDisabled: false,\r\n formatYear: 'yy',\r\n startingDay: 1\r\n };\r\n var initializingAfter = true;\r\n var initializingBefore = true;\r\n $scope.afterDate = moment().subtract(30, \"days\").toDate();\r\n $scope.beforeDate = moment().add(1, \"days\").toDate();\r\n var historyInfoTypeUserEnabled = ConfigService.getSafe().logging.historyUserInfoType === 'USERNAME' || ConfigService.getSafe().logging.historyUserInfoType === 'BOTH';\r\n var historyInfoTypeIpEnabled = ConfigService.getSafe().logging.historyUserInfoType === 'IP' || ConfigService.getSafe().logging.historyUserInfoType === 'BOTH';\r\n $scope.foo = {\r\n includeDisabledIndexersInStats: localStorageService.get(\"includeDisabledIndexersInStats\") !== null ? localStorageService.get(\"includeDisabledIndexersInStats\") : false,\r\n statsSwichState: localStorageService.get(\"statsSwitchState\") !== null ? localStorageService.get(\"statsSwitchState\") :\r\n {\r\n indexerApiAccessStats: true,\r\n avgIndexerUniquenessScore: true,\r\n avgResponseTimes: true,\r\n indexerDownloadShares: true,\r\n downloadsPerDayOfWeek: true,\r\n downloadsPerHourOfDay: true,\r\n searchesPerDayOfWeek: true,\r\n searchesPerHourOfDay: true,\r\n downloadsPerAgeStats: true,\r\n successfulDownloadsPerIndexer: true,\r\n downloadSharesPerUser: historyInfoTypeUserEnabled,\r\n searchSharesPerUser: historyInfoTypeIpEnabled,\r\n downloadSharesPerIp: true,\r\n searchSharesPerIp: true,\r\n userAgentSearchShares: true,\r\n userAgentDownloadShares: true\r\n }\r\n };\r\n localStorageService.set(\"statsSwitchState\", $scope.foo.statsSwichState);\r\n $scope.stats = {};\r\n\r\n updateStats();\r\n\r\n\r\n $scope.openAfter = function () {\r\n $scope.after.opened = true;\r\n };\r\n\r\n $scope.openBefore = function () {\r\n $scope.before.opened = true;\r\n };\r\n\r\n $scope.after = {\r\n opened: false\r\n };\r\n\r\n $scope.before = {\r\n opened: false\r\n };\r\n\r\n $scope.toggleIncludeDisabledIndexers = function () {\r\n localStorageService.set(\"includeDisabledIndexersInStats\", $scope.foo.includeDisabledIndexersInStats);\r\n };\r\n\r\n $scope.onStatsSwitchToggle = function (statId) {\r\n localStorageService.set(\"statsSwitchState\", $scope.foo.statsSwichState);\r\n\r\n if ($scope.foo.statsSwichState[statId]) { //Stat was enabled, get only data for this stat\r\n updateStats(statId);\r\n }\r\n\r\n };\r\n\r\n $scope.refresh = function () {\r\n updateStats();\r\n };\r\n\r\n function updateStats(statId) {\r\n blockUI.start(\"Updating stats...\");\r\n var after = $scope.afterDate !== null ? $scope.afterDate : null;\r\n var before = $scope.beforeDate !== null ? $scope.beforeDate : null;\r\n var statsToRetrieve = {};\r\n if (angular.isDefined(statId)) {\r\n statsToRetrieve[statId] = true;\r\n } else {\r\n statsToRetrieve = $scope.foo.statsSwichState;\r\n }\r\n $scope.statsLoadingPromise = StatsService.get(after, before, $scope.foo.includeDisabledIndexersInStats, statsToRetrieve).then(function (stats) {\r\n $scope.setStats(stats);\r\n //Resize event is needed for the -perUsernameOrIp charts to be properly sized because nvd3 thinks the initial size is 0\r\n $timeout(function () {\r\n $window.dispatchEvent(new Event(\"resize\"));\r\n }, 500);\r\n });\r\n\r\n blockUI.reset();\r\n }\r\n\r\n $scope.$watch('beforeDate', function () {\r\n if (initializingBefore) {\r\n initializingBefore = false;\r\n } else {\r\n //updateStats();\r\n }\r\n });\r\n\r\n\r\n $scope.$watch('afterDate', function () {\r\n if (initializingAfter) {\r\n initializingAfter = false;\r\n } else {\r\n //updateStats();\r\n }\r\n });\r\n\r\n $scope.onKeypress = function (keyEvent) {\r\n if (keyEvent.which === 13) {\r\n //updateStats();\r\n }\r\n };\r\n\r\n\r\n $scope.formats = ['dd-MMMM-yyyy', 'yyyy/MM/dd', 'dd.MM.yyyy', 'shortDate'];\r\n $scope.format = $scope.formats[0];\r\n $scope.altInputFormats = ['M!/d!/yyyy'];\r\n\r\n $scope.setStats = function (stats) {\r\n //Only update those stats that were calculated (because this might be an update when one stat has just been enabled)\r\n _.forEach(stats, function (value, key) {\r\n if (value !== null) {\r\n $scope.stats[key] = value;\r\n }\r\n });\r\n\r\n if ($scope.stats.avgResponseTimes) {\r\n $scope.avgResponseTimesChart = getChart(\"multiBarHorizontalChart\", $scope.stats.avgResponseTimes, \"indexer\", \"avgResponseTime\", \"\", \"Response time (ms)\");\r\n $scope.avgResponseTimesChart.options.chart.margin.left = 100;\r\n $scope.avgResponseTimesChart.options.chart.yAxis.rotateLabels = -30;\r\n $scope.avgResponseTimesChart.options.chart.height = Math.max($scope.stats.avgResponseTimes.length * 30, 350);\r\n }\r\n\r\n if ($scope.stats.downloadsPerHourOfDay) {\r\n $scope.downloadsPerHourOfDayChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerHourOfDay, \"hour\", \"count\", \"Hour of day\", 'Downloads');\r\n $scope.downloadsPerHourOfDayChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.downloadsPerDayOfWeek) {\r\n $scope.downloadsPerDayOfWeekChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerDayOfWeek, \"day\", \"count\", \"Day of week\", 'Downloads');\r\n $scope.downloadsPerDayOfWeekChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.searchesPerHourOfDay) {\r\n $scope.searchesPerHourOfDayChart = getChart(\"discreteBarChart\", $scope.stats.searchesPerHourOfDay, \"hour\", \"count\", \"Hour of day\", 'Searches');\r\n $scope.searchesPerHourOfDayChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.searchesPerDayOfWeek) {\r\n $scope.searchesPerDayOfWeekChart = getChart(\"discreteBarChart\", $scope.stats.searchesPerDayOfWeek, \"day\", \"count\", \"Day of week\", 'Searches');\r\n $scope.searchesPerDayOfWeekChart.options.chart.xAxis.rotateLabels = 0;\r\n }\r\n\r\n if ($scope.stats.downloadsPerAgeStats) {\r\n $scope.downloadsPerAgeChart = getChart(\"discreteBarChart\", $scope.stats.downloadsPerAgeStats.downloadsPerAge, \"age\", \"count\", \"Downloads per age\", 'Downloads');\r\n $scope.downloadsPerAgeChart.options.chart.xAxis.rotateLabels = 45;\r\n $scope.downloadsPerAgeChart.options.chart.showValues = false;\r\n }\r\n\r\n if ($scope.stats.successfulDownloadsPerIndexer) {\r\n $scope.successfulDownloadsPerIndexerChart = getChart(\"multiBarHorizontalChart\", $scope.stats.successfulDownloadsPerIndexer, \"indexerName\", \"percentSuccessful\", \"Indexer\", '% successful');\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.xAxis.rotateLabels = 90;\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.yAxis.tickFormat = function (d) {\r\n return $filter('number')(d, 0);\r\n };\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.valueFormat = function (d) {\r\n return $filter('number')(d, 0);\r\n };\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.showValues = true;\r\n $scope.successfulDownloadsPerIndexerChart.options.chart.margin.left = 80;\r\n }\r\n\r\n if ($scope.stats.indexerDownloadShares) {\r\n $scope.indexerDownloadSharesChart = {\r\n options: {\r\n chart: {\r\n type: 'pieChart',\r\n height: 500,\r\n x: function (d) {\r\n return d.indexerName;\r\n },\r\n y: function (d) {\r\n return d.share;\r\n },\r\n showLabels: true,\r\n donut: true,\r\n donutRatio: 0.35,\r\n duration: 500,\r\n labelThreshold: 0.03,\r\n labelSunbeamLayout: true,\r\n tooltip: {\r\n valueFormatter: function (d, i) {\r\n return $filter('number')(d, 2) + \"%\";\r\n }\r\n },\r\n legend: {\r\n margin: {\r\n top: 5,\r\n right: 35,\r\n bottom: 5,\r\n left: 0\r\n }\r\n }\r\n }\r\n },\r\n data: $scope.stats.indexerDownloadShares\r\n };\r\n $scope.indexerDownloadSharesChart.options.chart.height = Math.min(Math.max(($scope.foo.includeDisabledIndexersInStats ? $scope.stats.numberOfConfiguredIndexers : $scope.stats.numberOfEnabledIndexers) * 40, 350), 900);\r\n }\r\n\r\n function getSharesPieChart(data, height, xValue, yValue) {\r\n return {\r\n options: {\r\n chart: {\r\n type: 'pieChart',\r\n height: height,\r\n x: function (d) {\r\n return d[xValue];\r\n },\r\n y: function (d) {\r\n return d[yValue];\r\n },\r\n showLabels: true,\r\n donut: true,\r\n donutRatio: 0.35,\r\n duration: 500,\r\n labelThreshold: 0.03,\r\n labelsOutside: true,\r\n //labelType: \"percent\",\r\n labelSunbeamLayout: true,\r\n tooltip: {\r\n valueFormatter: function (d, i) {\r\n return $filter('number')(d, 2) + \"%\";\r\n }\r\n },\r\n legend: {\r\n margin: {\r\n top: 5,\r\n right: 35,\r\n bottom: 5,\r\n left: 0\r\n }\r\n }\r\n }\r\n },\r\n data: data\r\n };\r\n }\r\n\r\n if ($scope.stats.searchSharesPerIp !== null) {\r\n $scope.downloadSharesPerIpChart = getSharesPieChart($scope.stats.downloadSharesPerIp, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerIpChart !== null) {\r\n $scope.searchSharesPerIpChart = getSharesPieChart($scope.stats.searchSharesPerIp, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerUser !== null) {\r\n $scope.downloadSharesPerUserChart = getSharesPieChart($scope.stats.downloadSharesPerUser, 300, \"key\", \"percentage\");\r\n }\r\n if ($scope.stats.searchSharesPerUserChart !== null) {\r\n $scope.searchSharesPerUserChart = getSharesPieChart($scope.stats.searchSharesPerUser, 300, \"key\", \"percentage\");\r\n }\r\n\r\n if ($scope.stats.userAgentSearchShares) {\r\n $scope.userAgentSearchSharesChart = getSharesPieChart($scope.stats.userAgentSearchShares, 300, \"userAgent\", \"percentage\");\r\n $scope.userAgentSearchSharesChart.options.chart.legend.margin.bottom = 25;\r\n }\r\n if ($scope.stats.userAgentDownloadShares) {\r\n $scope.userAgentDownloadSharesChart = getSharesPieChart($scope.stats.userAgentDownloadShares, 300, \"userAgent\", \"percentage\");\r\n $scope.userAgentDownloadSharesChart.options.chart.legend.margin.bottom = 25;\r\n }\r\n\r\n };\r\n\r\n function getChart(chartType, values, xKey, yKey, xAxisLabel, yAxisLabel) {\r\n return {\r\n options: {\r\n chart: {\r\n type: chartType,\r\n height: 350,\r\n margin: {\r\n top: 20,\r\n right: 20,\r\n bottom: 100,\r\n left: 50\r\n },\r\n x: function (d) {\r\n return d[xKey];\r\n },\r\n y: function (d) {\r\n return d[yKey];\r\n },\r\n showValues: true,\r\n valueFormat: function (d) {\r\n return d;\r\n },\r\n color: function () {\r\n return \"red\"\r\n },\r\n showControls: false,\r\n showLegend: false,\r\n duration: 100,\r\n xAxis: {\r\n axisLabel: xAxisLabel,\r\n tickFormat: function (d) {\r\n return d;\r\n },\r\n rotateLabels: 30,\r\n showMaxMin: false,\r\n color: function () {\r\n return \"white\"\r\n }\r\n },\r\n yAxis: {\r\n axisLabel: yAxisLabel,\r\n axisLabelDistance: -10,\r\n tickFormat: function (d) {\r\n return d;\r\n }\r\n },\r\n tooltip: {\r\n enabled: false\r\n },\r\n zoom: {\r\n enabled: true,\r\n scaleExtent: [1, 10],\r\n useFixedDomain: false,\r\n useNiceScale: false,\r\n horizontalOff: false,\r\n verticalOff: true,\r\n unzoomEventType: 'dblclick.zoom'\r\n }\r\n }\r\n }, data: [{\r\n \"key\": \"doesntmatter\",\r\n \"bar\": true,\r\n \"values\": values\r\n }]\r\n };\r\n }\r\n}\r\n","//\nSearchService.$inject = [\"$http\"];\nangular\n .module('nzbhydraApp')\n .factory('SearchService', SearchService);\n\nfunction SearchService($http) {\n\n\n var lastExecutedQuery;\n var lastExecutedSearchRequestParameters;\n var lastResults;\n var modalInstance;\n\n return {\n search: search,\n getLastResults: getLastResults,\n loadMore: loadMore,\n shortcutSearch: shortcutSearch,\n getModalInstance: getModalInstance,\n setModalInstance: setModalInstance,\n };\n\n function getModalInstance() {\n return modalInstance;\n }\n\n function setModalInstance(mi) {\n modalInstance = mi;\n }\n\n function search(searchRequestId, category, query, metaData, season, episode, minsize, maxsize, minage, maxage, indexers, mode) {\n // console.time(\"search\");\n var uri = new URI(\"internalapi/search\");\n var searchRequestParameters = {};\n searchRequestParameters.searchRequestId = searchRequestId;\n searchRequestParameters.query = query;\n searchRequestParameters.minsize = minsize;\n searchRequestParameters.maxsize = maxsize;\n searchRequestParameters.minage = minage;\n searchRequestParameters.maxage = maxage;\n searchRequestParameters.category = category;\n searchRequestParameters.mode = mode;\n if (!angular.isUndefined(indexers) && indexers !== null) {\n searchRequestParameters.indexers = indexers.split(\",\");\n }\n\n if (metaData) {\n searchRequestParameters.title = metaData.title;\n if (category.indexOf(\"Movies\") > -1 || (category.indexOf(\"20\") === 0) || mode === \"movie\") {\n searchRequestParameters.tmdbId = metaData.tmdbId;\n searchRequestParameters.imdbId = metaData.imdbId;\n } else if (category.indexOf(\"TV\") > -1 || (category.indexOf(\"50\") === 0) || mode === \"tvsearch\") {\n searchRequestParameters.tvdbId = metaData.tvdbId;\n searchRequestParameters.imdbId = metaData.imdbId;\n searchRequestParameters.tvrageId = metaData.rid;\n searchRequestParameters.tvmazeId = metaData.tvmazeId;\n searchRequestParameters.season = season;\n searchRequestParameters.episode = episode;\n }\n }\n\n lastExecutedQuery = uri;\n lastExecutedSearchRequestParameters = searchRequestParameters;\n return $http.post(uri.toString(), searchRequestParameters).then(processData);\n }\n\n function loadMore(offset, limit, loadAll) {\n lastExecutedSearchRequestParameters.offset = offset;\n lastExecutedSearchRequestParameters.limit = limit;\n lastExecutedSearchRequestParameters.loadAll = angular.isDefined(loadAll) ? loadAll : false;\n\n return $http.post(lastExecutedQuery.toString(), lastExecutedSearchRequestParameters).then(processData);\n }\n\n function shortcutSearch(searchRequestId) {\n return $http.post(\"internalapi/shortcutSearch/\" + searchRequestId);\n }\n\n function processData(response) {\n var searchResults = response.data.searchResults;\n var indexerSearchMetaDatas = response.data.indexerSearchMetaDatas;\n var numberOfAvailableResults = response.data.numberOfAvailableResults;\n var numberOfRejectedResults = response.data.numberOfRejectedResults;\n var numberOfDuplicateResults = response.data.numberOfDuplicateResults;\n var numberOfAcceptedResults = response.data.numberOfAcceptedResults;\n var numberOfProcessedResults = response.data.numberOfProcessedResults;\n var rejectedReasonsMap = response.data.rejectedReasonsMap;\n var notPickedIndexersWithReason = response.data.notPickedIndexersWithReason;\n\n lastResults = {\n \"searchResults\": searchResults,\n \"indexerSearchMetaDatas\": indexerSearchMetaDatas,\n \"numberOfAvailableResults\": numberOfAvailableResults,\n \"numberOfAcceptedResults\": numberOfAcceptedResults,\n \"numberOfRejectedResults\": numberOfRejectedResults,\n \"numberOfProcessedResults\": numberOfProcessedResults,\n \"numberOfDuplicateResults\": numberOfDuplicateResults,\n \"rejectedReasonsMap\": rejectedReasonsMap,\n \"notPickedIndexersWithReason\": notPickedIndexersWithReason\n\n };\n // console.timeEnd(\"searchonly\");\n return lastResults;\n }\n\n function getLastResults() {\n return lastResults;\n }\n}","\nSearchResultsController.$inject = [\"$stateParams\", \"$scope\", \"$q\", \"$timeout\", \"$document\", \"blockUI\", \"growl\", \"localStorageService\", \"SearchService\", \"ConfigService\", \"CategoriesService\", \"DebugService\", \"GenericStorageService\", \"ModalService\", \"$uibModal\"];angular\n .module('nzbhydraApp')\n .controller('SearchResultsController', SearchResultsController);\n\n//SearchResultsController.$inject = ['blockUi'];\nfunction SearchResultsController($stateParams, $scope, $q, $timeout, $document, blockUI, growl, localStorageService, SearchService, ConfigService, CategoriesService, DebugService, GenericStorageService, ModalService, $uibModal) {\n // console.time(\"Presenting\");\n DebugService.log(\"foobar\");\n $scope.limitTo = ConfigService.getSafe().searching.loadLimitInternal;\n $scope.offset = 0;\n $scope.allowZipDownload = ConfigService.getSafe().downloading.fileDownloadAccessType === 'PROXY';\n\n var indexerColors = {};\n\n _.each(ConfigService.getSafe().indexers, function (indexer) {\n indexerColors[indexer.name] = indexer.color;\n });\n\n //Handle incoming data\n\n $scope.indexersearches = SearchService.getLastResults().indexerSearchMetaDatas;\n $scope.notPickedIndexersWithReason = [];\n _.forEach(SearchService.getLastResults().notPickedIndexersWithReason, function (k, v) {\n $scope.notPickedIndexersWithReason.push({\"indexer\": v, \"reason\": k});\n });\n $scope.indexerResultsInfo = {}; //Stores information about the indexerName's searchResults like how many we already retrieved\n $scope.groupExpanded = {};\n $scope.selected = [];\n if ($stateParams.title) {\n $scope.searchTitle = $stateParams.title;\n } else if ($stateParams.query) {\n $scope.searchTitle = $stateParams.query;\n } else {\n $scope.searchTitle = undefined;\n }\n\n $scope.selectedIds = _.map($scope.selected, function (value) {\n return value.searchResultId;\n });\n\n //For shift clicking results\n $scope.lastClickedValue = null;\n\n var allSearchResults = [];\n var sortModel = {};\n $scope.filterModel = {};\n\n\n $scope.filterButtonsModel = {\n source: {},\n quality: {},\n other: {},\n custom: {}\n };\n $scope.customFilterButtons = [];\n\n $scope.filterButtonsModelMap = {\n tv: ['hdtv'],\n camts: ['cam', 'ts'],\n web: ['webrip', 'web-dl', 'webdl'],\n dvd: ['dvd'],\n bluray: ['bluray', 'blu-ray']\n };\n _.each(ConfigService.getSafe().searching.customQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"=\");\n var displayName = split1[0];\n $scope.filterButtonsModelMap[displayName] = split1[1].split(\",\");\n $scope.customFilterButtons.push(displayName);\n });\n _.each(ConfigService.getSafe().searching.preselectQuickFilterButtons, function (entry) {\n var split1 = entry.split(\"|\");\n var category = split1[0];\n var id = split1[1];\n if (category !== 'source' || $scope.isShowFilterButtonsVideo) {\n $scope.filterButtonsModel[category][id] = true;\n }\n })\n\n $scope.numberOfFilteredResults = 0;\n\n\n if ($stateParams.sortby !== undefined) {\n $stateParams.sortby = $stateParams.sortby.toLowerCase();\n sortModel = {};\n sortModel.reversed = false;\n if ($stateParams.sortby === \"title\") {\n sortModel.column = \"title\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"indexer\") {\n sortModel.column = \"indexer\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"category\") {\n sortModel.column = \"category\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"size\") {\n sortModel.column = \"size\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"details\") {\n sortModel.column = \"grabs\";\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 1;\n } else {\n sortModel.sortMode = 2;\n }\n } else if ($stateParams.sortby === \"age\") {\n sortModel.column = \"epoch\";\n sortModel.reversed = true;\n if ($stateParams.sortdirection === \"asc\" || $stateParams.sortdirection === undefined) {\n sortModel.sortMode = 2;\n } else {\n sortModel.sortMode = 1;\n }\n }\n\n\n } else if (localStorageService.get(\"sorting\") !== null) {\n sortModel = localStorageService.get(\"sorting\");\n } else {\n sortModel = {\n column: \"epoch\",\n sortMode: 2,\n reversed: false\n };\n }\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode, sortModel.reversed);\n }, 10);\n\n\n $scope.foo = {\n indexerStatusesExpanded: localStorageService.get(\"indexerStatusesExpanded\") !== null ? localStorageService.get(\"indexerStatusesExpanded\") : false,\n duplicatesDisplayed: localStorageService.get(\"duplicatesDisplayed\") !== null ? localStorageService.get(\"duplicatesDisplayed\") : false,\n groupTorrentAndNewznabResults: localStorageService.get(\"groupTorrentAndNewznabResults\") !== null ? localStorageService.get(\"groupTorrentAndNewznabResults\") : false,\n sumGrabs: localStorageService.get(\"sumGrabs\") !== null ? localStorageService.get(\"sumGrabs\") : true,\n scrollToResults: localStorageService.get(\"scrollToResults\") !== null ? localStorageService.get(\"scrollToResults\") : true,\n showCovers: localStorageService.get(\"showCovers\") !== null ? localStorageService.get(\"showCovers\") : true,\n groupEpisodes: localStorageService.get(\"groupEpisodes\") !== null ? localStorageService.get(\"groupEpisodes\") : true,\n expandGroupsByDefault: localStorageService.get(\"expandGroupsByDefault\") !== null ? localStorageService.get(\"expandGroupsByDefault\") : false,\n showDownloadedIndicator: localStorageService.get(\"showDownloadedIndicator\") !== null ? localStorageService.get(\"showDownloadedIndicator\") : true,\n hideAlreadyDownloadedResults: localStorageService.get(\"hideAlreadyDownloadedResults\") !== null ? localStorageService.get(\"hideAlreadyDownloadedResults\") : true,\n showResultsAsZipButton: localStorageService.get(\"showResultsAsZipButton\") !== null ? localStorageService.get(\"showResultsAsZipButton\") : true,\n alwaysShowTitles: localStorageService.get(\"alwaysShowTitles\") !== null ? localStorageService.get(\"alwaysShowTitles\") : true\n };\n\n\n $scope.isShowFilterButtons = ConfigService.getSafe().searching.showQuickFilterButtons;\n $scope.isShowFilterButtonsVideo = $scope.isShowFilterButtons && ($stateParams.category.toLowerCase().indexOf(\"tv\") > -1 || $stateParams.category.toLowerCase().indexOf(\"movie\") > -1 || ConfigService.getSafe().searching.alwaysShowQuickFilterButtons);\n $scope.isShowCustomFilterButtons = ConfigService.getSafe().searching.customQuickFilterButtons.length > 0;\n\n $scope.shared = {\n isGroupEpisodes: $scope.foo.groupEpisodes && $stateParams.category.toLowerCase().indexOf(\"tv\") > -1 && $stateParams.episode === undefined,\n expandGroupsByDefault: $scope.foo.expandGroupsByDefault,\n showDownloadedIndicator: $scope.foo.showDownloadedIndicator,\n hideAlreadyDownloadedResults: $scope.foo.hideAlreadyDownloadedResults,\n alwaysShowTitles: $scope.foo.alwaysShowTitles\n };\n\n if ($scope.shared.isGroupEpisodes) {\n GenericStorageService.get(\"isGroupEpisodesHelpShown\", true).then(function (response) {\n if (!response.data) {\n ModalService.open(\"Sorting of TV episodes\", 'When searching in the TV categories results are automatically grouped by episodes. This makes it easier to download one episode each. You can disable this feature any time using the \"Display options\" button to the upper left.', {\n yes: {\n text: \"OK\"\n }\n });\n GenericStorageService.put(\"isGroupEpisodesHelpShown\", true, true);\n }\n\n })\n }\n\n $scope.loadMoreEnabled = false;\n $scope.totalAvailableUnknown = false;\n $scope.expandedTitlegroups = [];\n $scope.optionsOptions = [\n {id: \"duplicatesDisplayed\", label: \"Show duplicate display triggers\"},\n {id: \"groupTorrentAndNewznabResults\", label: \"Group torrent and usenet results\"},\n {id: \"sumGrabs\", label: \"Use sum of grabs / seeders for filtering / sorting of groups\"},\n {id: \"scrollToResults\", label: \"Scroll to results when finished\"},\n {id: \"showCovers\", label: \"Show movie covers in results\"},\n {id: \"groupEpisodes\", label: \"Group TV results by season/episode\"},\n {id: \"expandGroupsByDefault\", label: \"Expand groups by default\"},\n {id: \"alwaysShowTitles\", label: \"Always show result titles (even when grouped)\"},\n {id: \"showDownloadedIndicator\", label: \"Show already downloaded indicator\"},\n {id: \"hideAlreadyDownloadedResults\", label: \"Hide already downloaded results\"}\n ];\n if ($scope.allowZipDownload) {\n $scope.optionsOptions.push({id: \"showResultsAsZipButton\", label: \"Show button to download results as ZIP\"});\n }\n $scope.optionsSelectedModel = [];\n for (var key in $scope.optionsOptions) {\n if ($scope.foo[$scope.optionsOptions[key][\"id\"]]) {\n $scope.optionsSelectedModel.push($scope.optionsOptions[key].id);\n }\n }\n\n $scope.optionsExtraSettings = {\n showSelectAll: false,\n showDeselectAll: false,\n buttonText: \"Display options\"\n };\n\n $scope.optionsEvents = {\n onToggleItem: function (item, newValue) {\n if (item.id === \"duplicatesDisplayed\") {\n toggleDuplicatesDisplayed(newValue);\n } else if (item.id === \"groupTorrentAndNewznabResults\") {\n toggleGroupTorrentAndNewznabResults(newValue);\n } else if (item.id === \"sumGrabs\") {\n toggleSumGrabs(newValue);\n } else if (item.id === \"scrollToResults\") {\n toggleScrollToResults(newValue);\n } else if (item.id === \"showCovers\") {\n toggleShowCovers(newValue);\n } else if (item.id === \"groupEpisodes\") {\n toggleGroupEpisodes(newValue);\n } else if (item.id === \"expandGroupsByDefault\") {\n toggleExpandGroups(newValue);\n } else if (item.id === \"showDownloadedIndicator\") {\n toggleDownloadedIndicator(newValue);\n } else if (item.id === \"hideAlreadyDownloadedResults\") {\n toggleHideAlreadyDownloadedResults(newValue);\n } else if (item.id === \"showResultsAsZipButton\") {\n toggleShowResultsAsZipButton(newValue);\n } else if (item.id === \"alwaysShowTitles\") {\n toggleAlwaysShowTitles(newValue);\n }\n }\n };\n\n function toggleDuplicatesDisplayed(value) {\n localStorageService.set(\"duplicatesDisplayed\", value);\n $scope.$broadcast(\"duplicatesDisplayed\", value);\n $scope.foo.duplicatesDisplayed = value;\n $scope.shared.duplicatesDisplayed = value;\n }\n\n function toggleGroupTorrentAndNewznabResults(value) {\n localStorageService.set(\"groupTorrentAndNewznabResults\", value);\n $scope.foo.groupTorrentAndNewznabResults = value;\n $scope.shared.groupTorrentAndNewznabResults = value;\n blockAndUpdate();\n }\n\n function toggleSumGrabs(value) {\n localStorageService.set(\"sumGrabs\", value);\n $scope.foo.sumGrabs = value;\n $scope.shared.sumGrabs = value;\n blockAndUpdate();\n }\n\n function toggleScrollToResults(value) {\n localStorageService.set(\"scrollToResults\", value);\n $scope.foo.scrollToResults = value;\n $scope.shared.scrollToResults = value;\n }\n\n function toggleShowCovers(value) {\n localStorageService.set(\"showCovers\", value);\n $scope.foo.showCovers = value;\n $scope.shared.showCovers = value;\n $scope.$broadcast(\"toggleShowCovers\", value);\n }\n\n function toggleGroupEpisodes(value) {\n localStorageService.set(\"groupEpisodes\", value);\n $scope.shared.isGroupEpisodes = value;\n $scope.foo.isGroupEpisodes = value;\n blockAndUpdate();\n }\n\n function toggleExpandGroups(value) {\n localStorageService.set(\"expandGroupsByDefault\", value);\n $scope.shared.isExpandGroupsByDefault = value;\n $scope.foo.isExpandGroupsByDefault = value;\n blockAndUpdate();\n }\n\n function toggleDownloadedIndicator(value) {\n localStorageService.set(\"showDownloadedIndicator\", value);\n $scope.shared.showDownloadedIndicator = value;\n $scope.foo.showDownloadedIndicator = value;\n blockAndUpdate();\n }\n\n function toggleHideAlreadyDownloadedResults(value) {\n localStorageService.set(\"hideAlreadyDownloadedResults\", value);\n $scope.foo.hideAlreadyDownloadedResults = value;\n blockAndUpdate();\n }\n\n function toggleShowResultsAsZipButton(value) {\n localStorageService.set(\"showResultsAsZipButton\", value);\n $scope.shared.showResultsAsZipButton = value;\n $scope.foo.showResultsAsZipButton = value;\n }\n\n function toggleAlwaysShowTitles(value) {\n localStorageService.set(\"alwaysShowTitles\", value);\n $scope.shared.alwaysShowTitles = value;\n $scope.foo.alwaysShowTitles = value;\n $scope.$broadcast(\"toggleAlwaysShowTitles\", value);\n }\n\n\n $scope.indexersForFiltering = [];\n _.forEach($scope.indexersearches, function (indexer) {\n $scope.indexersForFiltering.push({label: indexer.indexerName, id: indexer.indexerName})\n });\n $scope.categoriesForFiltering = [];\n _.forEach(CategoriesService.getWithoutAll(), function (category) {\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\n });\n _.forEach($scope.indexersearches, function (ps) {\n $scope.indexerResultsInfo[ps.indexerName.toLowerCase()] = {loadedResults: ps.loaded_results};\n });\n\n setDataFromSearchResult(SearchService.getLastResults(), []);\n $scope.$emit(\"searchResultsShown\");\n\n if (!SearchService.getLastResults().searchResults || SearchService.getLastResults().searchResults.length === 0 || $scope.allResultsFiltered || $scope.numberOfAcceptedResults === 0) {\n //Close modal instance because no search results will be rendered that could trigger the closing\n console.log(\"CLosing status window\");\n SearchService.getModalInstance().close();\n $scope.doShowResults = true;\n } else {\n console.log(\"Will leave the closing of the status window to finishRendering. # of search results: \" + SearchService.getLastResults().searchResults.length + \". All results filtered: \" + $scope.allResultsFiltered);\n }\n\n //Returns the content of the property (defined by the current sortPredicate) of the first group element\n $scope.firstResultPredicate = firstResultPredicate;\n\n function firstResultPredicate(item) {\n return item[0][$scope.sortPredicate];\n }\n\n //Returns the unique group identifier which allows angular to keep track of the grouped search results even after filtering, making filtering by indexers a lot faster (albeit still somewhat slow...)\n $scope.groupId = groupId;\n\n function groupId(item) {\n return item[0][0].searchResultId;\n }\n\n $scope.onFilterButtonsModelChange = function () {\n console.log($scope.filterButtonsModel);\n blockAndUpdate();\n };\n\n function blockAndUpdate() {\n startBlocking(\"Sorting / filtering...\").then(function () {\n [$scope.filteredResults, $scope.filterReasons] = sortAndFilter(allSearchResults);\n localStorageService.set(\"sorting\", sortModel);\n });\n }\n\n //Block the UI and return after timeout. This way we make sure that the blocking is done before angular starts updating the model/view. There's probably a better way to achieve that?\n function startBlocking(message) {\n var deferred = $q.defer();\n blockUI.start(message);\n $timeout(function () {\n deferred.resolve();\n }, 10);\n return deferred.promise;\n }\n\n $scope.$on(\"sort\", function (event, column, sortMode, reversed) {\n if (sortMode === 0) {\n sortModel = {\n column: \"epoch\",\n sortMode: 2,\n reversed: true\n };\n } else {\n sortModel = {\n column: column,\n sortMode: sortMode,\n reversed: reversed\n };\n }\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode, sortModel.reversed);\n }, 10);\n blockAndUpdate();\n });\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue && isActive) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n blockAndUpdate();\n });\n\n $scope.resort = function () {\n };\n\n function getCleanedTitle(element) {\n try {\n return element.title.toLowerCase().replace(/[\\s\\-\\._]/ig, \"\");\n } catch (e) {\n console.error(\"Unable to clean title for result \" + element);\n }\n }\n\n function getGroupingString(element) {\n\n var groupingString;\n if ($scope.shared.isGroupEpisodes) {\n groupingString = (element.showtitle + \"x\" + element.season + \"x\" + element.episode).toLowerCase().replace(/[\\._\\-]/ig, \"\");\n if (groupingString === \"nullxnullxnull\") {\n groupingString = getCleanedTitle(element);\n }\n } else {\n groupingString = getCleanedTitle(element);\n if (!$scope.foo.groupTorrentAndNewznabResults) {\n groupingString = groupingString + element.downloadType;\n }\n }\n return groupingString;\n }\n\n function sortAndFilter(results) {\n var query;\n var words;\n var filterReasons = {\n \"tooSmall\": 0,\n \"tooLarge\": 0,\n \"tooYoung\": 0,\n \"tooOld\": 0,\n \"tooFewGrabs\": 0,\n \"tooManyGrabs\": 0,\n \"title\": 0,\n \"tooindexer\": 0,\n \"category\": 0,\n \"tooOld\": 0,\n \"quickFilter\": 0,\n \"alreadyDownloaded\": 0\n\n\n };\n\n if (\"title\" in $scope.filterModel) {\n query = $scope.filterModel.title.filterValue;\n if (!(query.startsWith(\"/\") && query.endsWith(\"/\"))) {\n words = query.toLowerCase().split(/[\\s.\\-]+/);\n }\n }\n\n function filter(item) {\n if (item.title === null || item.title === undefined) {\n //https://github.com/theotherp/nzbhydra2/issues/690\n console.error(\"Item without title: \" + JSON.stringify(item))\n }\n if (\"size\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.size.filterValue;\n if (angular.isDefined(filterValue.min) && item.size / 1024 / 1024 < filterValue.min) {\n filterReasons[\"tooSmall\"] = filterReasons[\"tooSmall\"] + 1;\n return false;\n }\n if (angular.isDefined(filterValue.max) && item.size / 1024 / 1024 > filterValue.max) {\n filterReasons[\"tooLarge\"] = filterReasons[\"tooLarge\"] + 1;\n return false;\n }\n }\n\n if (\"epoch\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.epoch.filterValue;\n\n if (angular.isDefined(filterValue.min)) {\n var min = filterValue.min;\n if (min.endsWith(\"h\")) {\n min = min.replace(\"h\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"hours\");\n } else if (min.endsWith(\"m\")) {\n min = min.replace(\"m\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"minutes\");\n } else {\n var age = moment.utc().diff(moment.unix(item.epoch), \"days\");\n }\n min = Number(min);\n if (age < min) {\n filterReasons[\"tooYoung\"] = filterReasons[\"tooYoung\"] + 1;\n return false;\n }\n }\n\n if (angular.isDefined(filterValue.max)) {\n var max = filterValue.max;\n if (max.endsWith(\"h\")) {\n max = max.replace(\"h\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"hours\");\n } else if (max.endsWith(\"m\")) {\n max = max.replace(\"m\", \"\");\n var age = moment.utc().diff(moment.unix(item.epoch), \"minutes\");\n } else {\n var age = moment.utc().diff(moment.unix(item.epoch), \"days\");\n }\n max = Number(max);\n if (age > max) {\n filterReasons[\"tooOld\"] = filterReasons[\"tooOld\"] + 1;\n return false;\n }\n }\n }\n\n\n if (\"grabs\" in $scope.filterModel) {\n var filterValue = $scope.filterModel.grabs.filterValue;\n if (angular.isDefined(filterValue.min)) {\n if ((item.seeders !== null && item.seeders < filterValue.min) || (item.seeders === null && item.grabs !== null && item.grabs < filterValue.min)) {\n filterReasons[\"tooFewGrabs\"] = filterReasons[\"tooFewGrabs\"] + 1;\n return false;\n }\n }\n if (angular.isDefined(filterValue.max)) {\n if ((item.seeders !== null && item.seeders > filterValue.max) || (item.seeders === null && item.grabs !== null && item.grabs > filterValue.max)) {\n filterReasons[\"tooManyGrabs\"] = filterReasons[\"tooManyGrabs\"] + 1;\n return false;\n }\n }\n }\n\n if (\"title\" in $scope.filterModel) {\n var ok;\n if (query.startsWith(\"/\") && query.endsWith(\"/\")) {\n ok = item.title.toLowerCase().match(new RegExp(query.substr(1, query.length - 2), \"gi\"));\n } else {\n ok = _.every(words, function (word) {\n if (word.startsWith(\"!\")) {\n if (word.length === 1) {\n return true;\n }\n return item.title.toLowerCase().indexOf(word.substring(1).toLowerCase()) === -1;\n }\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1;\n });\n }\n\n if (!ok) {\n filterReasons[\"title\"] = filterReasons[\"title\"] + 1;\n return false;\n }\n }\n if (\"indexer\" in $scope.filterModel) {\n if (_.indexOf($scope.filterModel.indexer.filterValue, item.indexer) === -1) {\n filterReasons[\"title\"] = filterReasons[\"title\"] + 1;\n return false;\n }\n }\n if (\"category\" in $scope.filterModel) {\n if (_.indexOf($scope.filterModel.category.filterValue, item.category) === -1) {\n filterReasons[\"category\"] = filterReasons[\"category\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.source !== null) {\n var mustContain = [];\n _.each($scope.filterButtonsModel.source, function (value, key) { //key is something like 'camts', value is true or false\n if (value) {\n Array.prototype.push.apply(mustContain, $scope.filterButtonsModelMap[key]);\n }\n });\n if (mustContain.length > 0) {\n var containsAtLeastOne = _.any(mustContain, function (word) {\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1\n });\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the words \" + JSON.stringify(mustContain));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n }\n if ($scope.filterButtonsModel.quality !== null && !_.isEmpty($scope.filterButtonsModel.quality)) {\n //key is something like 'q720p', value is true or false.\n var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.quality, function (value, key) {\n return value\n }));\n if (requiresAnyOf.length === 0) {\n return true;\n }\n\n var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n if (item.title.toLowerCase().indexOf(required.substring(1).toLowerCase()) > -1) {\n //We need to remove the \"q\" which is there because keys may not start with a digit\n return true;\n }\n })\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the qualities \" + JSON.stringify(requiresAnyOf));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.other !== null && !_.isEmpty($scope.filterButtonsModel.other)) {\n var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.other, function (value, key) {\n return value\n }));\n if (requiresAnyOf.length === 0) {\n return true;\n }\n var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n if (item.title.toLowerCase().indexOf(required.toLowerCase()) > -1) {\n return true;\n }\n })\n if (!containsAtLeastOne) {\n console.debug(item.title + \" does not contain any of the 'other' values \" + JSON.stringify(requiresAnyOf));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if ($scope.filterButtonsModel.custom !== null && !_.isEmpty($scope.filterButtonsModel.custom)) {\n\n var quickFilterWords = [];\n var quickFilterRegexes = [];\n _.each($scope.filterButtonsModel.custom, function (value, key) { //key is something like 'camts', value is true or false\n if (value) {\n _.each($scope.filterButtonsModelMap[key], function (string) {\n if (string.startsWith(\"/\") && string.endsWith(\"/\")) {\n quickFilterRegexes.push(string);\n } else {\n Array.prototype.push.apply(quickFilterWords, string.split(\" \"));\n }\n });\n }\n });\n if (quickFilterWords.length !== 0) {\n var allMatch = _.all(quickFilterWords, function (word) {\n if (word.startsWith(\"!\")) {\n if (word.length === 1) {\n return true;\n }\n return item.title.toLowerCase().indexOf(word.substring(1).toLowerCase()) === -1;\n }\n return item.title.toLowerCase().indexOf(word.toLowerCase()) > -1;\n })\n\n if (!allMatch) {\n console.debug(item.title + \" does not match all the terms of \" + JSON.stringify(quickFilterWords));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n if (quickFilterRegexes.length !== 0) {\n var allMatch = _.all(quickFilterRegexes, function (regex) {\n return new RegExp(regex.toLowerCase().slice(1, -1)).test(item.title.toLowerCase());\n })\n\n if (!allMatch) {\n console.debug(item.title + \" does not match all the regexes of \" + JSON.stringify(quickFilterRegexes));\n filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n return false;\n }\n }\n\n\n // var requiresAnyOf = _.keys(_.pick($scope.filterButtonsModel.custom, function (value, key) {\n // return value\n // }));\n // if (requiresAnyOf.length === 0) {\n // return true;\n // }\n // var containsAtLeastOne = _.any(requiresAnyOf, function (required) {\n // if (item.title.toLowerCase().indexOf(required.toLowerCase()) > -1) {\n // return true;\n // }\n // })\n // if (!containsAtLeastOne) {\n // console.debug(item.title + \" does not contain any of the custom values' \" + JSON.stringify(requiresAnyOf));\n // filterReasons[\"quickFilter\"] = filterReasons[\"quickFilter\"] + 1;\n // return false;\n // }\n }\n\n if ($scope.foo.hideAlreadyDownloadedResults && item.downloadedAt !== null) {\n filterReasons[\"alreadyDownloaded\"] = filterReasons[\"alreadyDownloaded\"] + 1;\n return false;\n }\n\n return true;\n }\n\n\n var sortPredicateKey = sortModel.column;\n var sortReversed = sortModel.reversed;\n\n function getSortPredicateValue(containgObject) {\n var sortPredicateValue;\n if (sortPredicateKey === \"grabs\") {\n if (containgObject[\"seeders\"] !== null) {\n sortPredicateValue = containgObject[\"seeders\"];\n } else if (containgObject[\"grabs\"] !== null) {\n sortPredicateValue = containgObject[\"grabs\"];\n } else {\n sortPredicateValue = 0;\n }\n } else if (sortPredicateKey === \"title\") {\n sortPredicateValue = getCleanedTitle(containgObject);\n } else if (sortPredicateKey === \"indexer\") {\n sortPredicateValue = containgObject[\"indexer\"].toLowerCase();\n } else {\n sortPredicateValue = containgObject[sortPredicateKey];\n }\n return sortPredicateValue;\n }\n\n function createSortedHashgroups(titleGroup) {\n function createHashGroup(hashGroup) {\n //Sorting hash group's contents should not matter for size and age and title but might for category (we might remove this, it's probably mostly unnecessary)\n var sortedHashGroup = _.sortBy(hashGroup, function (item) {\n var sortPredicateValue = getSortPredicateValue(item);\n return sortReversed ? -sortPredicateValue : sortPredicateValue;\n });\n //Now sort the hash group by indexer score (inverted) so that the result with the highest indexer score is shown on top (or as the only one of a hash group if it's collapsed)\n sortedHashGroup = _.sortBy(sortedHashGroup, function (item) {\n return item.indexerscore * -1;\n });\n return sortedHashGroup;\n }\n\n function getHashGroupFirstElementSortPredicate(hashGroup) {\n if (sortPredicateKey === \"title\") {\n //Sorting a title group internally by title doesn't make sense so fall back to sorting by age so that newest result is at the top\n return ((10000000000 * hashGroup[0][\"indexerscore\"]) + hashGroup[0][\"epoch\"]) * -1;\n }\n return getSortPredicateValue(hashGroup[0]);\n }\n\n var grouped = _.groupBy(titleGroup, \"hash\");\n var mapped = _.map(grouped, createHashGroup);\n var sorted = _.sortBy(mapped, getHashGroupFirstElementSortPredicate);\n if (sortModel.sortMode === 2 && sortPredicateKey !== \"title\") {\n sorted = sorted.reverse();\n }\n\n return sorted;\n }\n\n function getTitleGroupFirstElementsSortPredicate(titleGroup) {\n var sortPredicateValue;\n if (sortPredicateKey === \"grabs\" && $scope.foo.sumGrabs) {\n var sumOfGrabs = 0;\n _.each(titleGroup, function (element1) {\n _.each(element1, function (element2) {\n sumOfGrabs += getSortPredicateValue(element2);\n })\n });\n\n sortPredicateValue = sumOfGrabs;\n } else {\n sortPredicateValue = getSortPredicateValue(titleGroup[0][0]);\n }\n return sortPredicateValue\n }\n\n _.each(results, function (result) {\n var indexerColor = indexerColors[result.indexer];\n if (indexerColor === undefined || indexerColor === null) {\n return \"\";\n }\n result.style = \"background-color: \" + indexerColor.replace(\"rgb\", \"rgba\").replace(\")\", \",0.5)\")\n });\n\n var filtered = _.filter(results, filter);\n $scope.numberOfFilteredResults = results.length - filtered.length;\n $scope.allResultsFiltered = results.length > 0 && ($scope.numberOfFilteredResults === results.length);\n console.log(\"Filtered \" + $scope.numberOfFilteredResults + \" out of \" + results.length);\n var newSelected = $scope.selected;\n _.forEach($scope.selected, function (x) {\n if (x === undefined) {\n return;\n }\n if (filtered.indexOf(x) === -1) {\n $scope.$broadcast(\"toggleSelection\", x, false);\n newSelected.splice($scope.selected.indexOf(x), 1);\n }\n });\n $scope.selected = newSelected;\n\n var grouped = _.groupBy(filtered, getGroupingString);\n\n var mapped = _.map(grouped, createSortedHashgroups);\n var sorted = _.sortBy(mapped, getTitleGroupFirstElementsSortPredicate);\n if (sortModel.sortMode === 2) {\n sorted = sorted.reverse();\n }\n\n var filteredResults = [];\n var countTitleGroups = 0;\n var countResultsUntilTitleGroupLimitReached = 0;\n _.forEach(sorted, function (titleGroup) {\n var titleGroupIndex = 0;\n countTitleGroups++;\n\n _.forEach(titleGroup, function (duplicateGroup) {\n var duplicateIndex = 0;\n _.forEach(duplicateGroup, function (result) {\n try {\n result.titleGroupIndicator = getGroupingString(result);\n result.titleGroupIndex = titleGroupIndex;\n result.duplicateGroupIndex = duplicateIndex;\n result.duplicatesLength = duplicateGroup.length;\n result.titlesLength = titleGroup.length;\n filteredResults.push(result);\n duplicateIndex += 1;\n if (countTitleGroups <= $scope.limitTo) {\n countResultsUntilTitleGroupLimitReached++;\n }\n if (duplicateGroup.length > 1)\n $scope.countDuplicates += (duplicateGroup.length - 1)\n } catch (e) {\n console.error(\"Error while processing result \" + result, e);\n }\n });\n titleGroupIndex += 1;\n });\n });\n $scope.limitTo = Math.max($scope.limitTo, countResultsUntilTitleGroupLimitReached);\n\n $scope.$broadcast(\"calculateDisplayState\");\n\n return [filteredResults, filterReasons];\n }\n\n $scope.toggleTitlegroupExpand = function toggleTitlegroupExpand(titleGroup) {\n $scope.groupExpanded[titleGroup[0][0].title] = !$scope.groupExpanded[titleGroup[0][0].title];\n $scope.groupExpanded[titleGroup[0][0].hash] = !$scope.groupExpanded[titleGroup[0][0].hash];\n };\n\n $scope.stopBlocking = stopBlocking;\n\n function stopBlocking() {\n blockUI.reset();\n }\n\n function setDataFromSearchResult(data, previousSearchResults) {\n allSearchResults = previousSearchResults.concat(data.searchResults);\n allSearchResults = uniq(allSearchResults);\n [$scope.filteredResults, $scope.filterReasons] = sortAndFilter(allSearchResults);\n\n $scope.numberOfAvailableResults = data.numberOfAvailableResults;\n $scope.rejectedReasonsMap = data.rejectedReasonsMap;\n $scope.anyResultsRejected = !_.isEmpty(data.rejectedReasonsMap);\n $scope.anyIndexersSearchedSuccessfully = _.any(data.indexerSearchMetaDatas, function (x) {\n return x.wasSuccessful;\n });\n $scope.numberOfAcceptedResults = data.numberOfAcceptedResults;\n $scope.numberOfRejectedResults = data.numberOfRejectedResults;\n $scope.numberOfProcessedResults = data.numberOfProcessedResults;\n $scope.numberOfDuplicateResults = data.numberOfDuplicateResults;\n $scope.numberOfLoadedResults = allSearchResults.length;\n $scope.indexersearches = data.indexerSearchMetaDatas;\n\n $scope.loadMoreEnabled = ($scope.numberOfLoadedResults + $scope.numberOfRejectedResults < $scope.numberOfAvailableResults) || _.any(data.indexerSearchMetaDatas, function (x) {\n return x.hasMoreResults;\n });\n $scope.totalAvailableUnknown = _.any(data.indexerSearchMetaDatas, function (x) {\n return !x.totalResultsKnown;\n });\n\n if (!$scope.foo.indexerStatusesExpanded && _.any(data.indexerSearchMetaDatas, function (x) {\n return !x.wasSuccessful;\n })) {\n growl.info(\"Errors occurred during searching, Check indexer statuses\")\n }\n //Only show those categories in filter that are actually present in the results\n $scope.categoriesForFiltering = [];\n var allUsedCategories = _.uniq(_.pluck(allSearchResults, \"category\"));\n _.forEach(CategoriesService.getWithoutAll(), function (category) {\n if (allUsedCategories.indexOf(category.name) > -1) {\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\n }\n });\n }\n\n function uniq(searchResults) {\n var seen = {};\n var out = [];\n var len = searchResults.length;\n var j = 0;\n for (var i = 0; i < len; i++) {\n var item = searchResults[i];\n if (seen[item.searchResultId] !== 1) {\n seen[item.searchResultId] = 1;\n out[j++] = item;\n }\n }\n return out;\n }\n\n $scope.loadMore = loadMore;\n\n function loadMore(loadAll) {\n startBlocking(loadAll ? \"Loading all results...\" : \"Loading more results...\").then(function () {\n $scope.loadingMore = true;\n var limit = loadAll ? $scope.numberOfAvailableResults - $scope.numberOfProcessedResults : null;\n SearchService.loadMore($scope.numberOfLoadedResults, limit, loadAll).then(function (data) {\n setDataFromSearchResult(data, allSearchResults);\n $scope.loadingMore = false;\n //stopBlocking();\n });\n });\n }\n\n\n $scope.countResults = countResults;\n\n function countResults() {\n return allSearchResults.length;\n }\n\n $scope.invertSelection = function invertSelection() {\n $scope.$broadcast(\"invertSelection\");\n };\n\n $scope.deselectAll = function deselectAll() {\n $scope.$broadcast(\"deselectAll\");\n };\n\n $scope.selectAll = function selectAll() {\n $scope.$broadcast(\"selectAll\");\n };\n\n $scope.toggleIndexerStatuses = function () {\n $scope.foo.indexerStatusesExpanded = !$scope.foo.indexerStatusesExpanded;\n localStorageService.set(\"indexerStatusesExpanded\", $scope.foo.indexerStatusesExpanded);\n };\n\n $scope.getRejectedReasonsTooltip = function () {\n if (_.isEmpty($scope.rejectedReasonsMap)) {\n return \"No rejected results\";\n } else {\n var tooltip = \"Rejected results:
                      \";\n tooltip += '';\n _.forEach($scope.rejectedReasonsMap, function (count, reason) {\n tooltip += '';\n });\n tooltip += '
                      CountReason
                      ' + count + '' + reason + '
                      ';\n tooltip += '
                      ';\n tooltip += \"Filtered results:
                      \";\n tooltip += '';\n _.forEach($scope.filterReasons, function (count, reason) {\n if (count > 0) {\n tooltip += '';\n }\n });\n tooltip += '
                      CountReason
                      ' + count + '' + reason + '
                      ';\n tooltip += '
                      '\n return tooltip;\n }\n };\n\n\n $scope.$on(\"checkboxClicked\", function (event, originalEvent, newCheckedValue, clickTargetElement) {\n if (originalEvent.shiftKey && $scope.lastClickedElement) {\n $scope.$broadcast(\"shiftClick\", Number($scope.lastClickedValue), $scope.lastClickedElement, clickTargetElement);\n }\n $scope.lastClickedElement = clickTargetElement;\n $scope.lastClickedValue = newCheckedValue;\n });\n\n $scope.$on(\"toggleTitleExpansionUp\", function ($event, value, titleGroupIndicator) {\n $scope.$broadcast(\"toggleTitleExpansionDown\", value, titleGroupIndicator);\n });\n\n $scope.$on(\"toggleDuplicateExpansionUp\", function ($event, value, hash) {\n $scope.$broadcast(\"toggleDuplicateExpansionDown\", value, hash);\n });\n\n $scope.$on(\"selectionUp\", function ($event, result, value) {\n var index = $scope.selected.indexOf(result);\n if (value && index === -1) {\n $scope.selected.push(result);\n } else if (!value && index > -1) {\n $scope.selected.splice(index, 1);\n }\n });\n\n $scope.downloadNzbsCallback = function (addedIds) {\n if (addedIds !== null && addedIds.length > 0) {\n growl.info(\"Removing downloaded NZBs from selection\");\n var toRemove = _.filter($scope.selected, function (x) {\n return addedIds.indexOf(Number(x.searchResultId)) > -1;\n });\n var newSelected = $scope.selected;\n _.forEach(toRemove, function (x) {\n $scope.$broadcast(\"toggleSelection\", x, false);\n newSelected.splice($scope.selected.indexOf(x), 1);\n });\n $scope.selected = newSelected;\n }\n };\n\n\n $scope.filterRejectedZero = function () {\n return function (entry) {\n return entry[1] > 0;\n }\n };\n\n $scope.onPageChange = function (newPageNumber, oldPageNumber) {\n _.each($scope.selected, function (x) {\n $scope.$broadcast(\"toggleSelection\", x, true);\n })\n };\n\n $scope.$on(\"onFinishRender\", function () {\n console.log(\"Finished rendering results.\")\n $scope.doShowResults = true;\n $timeout(function () {\n if ($scope.foo.scrollToResults) {\n var searchResultsElement = angular.element(document.getElementById('display-options'));\n $document.scrollToElement(searchResultsElement, 0, 500);\n }\n stopBlocking();\n console.log(\"Closing search status window because rendering is finished.\")\n SearchService.getModalInstance().close();\n }, 1);\n });\n\n\n $timeout(function () {\n DebugService.print();\n }, 3000);\n\n // $timeout(function () {\n // function getWatchers(root) {\n // root = angular.element(root || document.documentElement);\n // var watcherCount = 0;\n // var ids = [];\n //\n // function getElemWatchers(element, ids) {\n // var isolateWatchers = getWatchersFromScope(element.data().$isolateScope, ids);\n // var scopeWatchers = getWatchersFromScope(element.data().$scope, ids);\n // var watchers = scopeWatchers.concat(isolateWatchers);\n // angular.forEach(element.children(), function (childElement) {\n // watchers = watchers.concat(getElemWatchers(angular.element(childElement), ids));\n // });\n // return watchers;\n // }\n //\n // function getWatchersFromScope(scope, ids) {\n // if (scope) {\n // if (_.indexOf(ids, scope.$id) > -1) {\n // return [];\n // }\n // ids.push(scope.$id);\n // if (scope.$$watchers) {\n // if (scope.$$watchers.length > 1) {\n // var a;\n // a = 1;\n // }\n // return scope.$$watchers;\n // }\n // {\n // return [];\n // }\n //\n // } else {\n // return [];\n // }\n // }\n //\n // return getElemWatchers(root, ids);\n // }\n //\n // }, $scope.limitTo);\n}\n","\r\nSearchHistoryService.$inject = [\"$filter\", \"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('SearchHistoryService', SearchHistoryService);\r\n\r\nfunction SearchHistoryService($filter, $http) {\r\n\r\n return {\r\n getSearchHistory: getSearchHistory,\r\n getSearchHistoryForSearching: getSearchHistoryForSearching,\r\n formatRequest: formatRequest,\r\n getStateParamsForRepeatedSearch: getStateParamsForRepeatedSearch\r\n };\r\n\r\n function getSearchHistoryForSearching() {\r\n return $http.post(\"internalapi/history/searches/forsearching\").then(function (response) {\r\n return {\r\n searchRequests: response.data\r\n }\r\n });\r\n }\r\n\r\n function getSearchHistory(pageNumber, limit, filterModel, sortModel, distinct, onlyCurrentUser) {\r\n var params = {\r\n page: pageNumber,\r\n limit: limit,\r\n filterModel: filterModel,\r\n distinct: distinct,\r\n onlyCurrentUser: onlyCurrentUser\r\n };\r\n if (angular.isUndefined(pageNumber)) {\r\n params.page = 1;\r\n }\r\n if (angular.isUndefined(limit)) {\r\n params.limit = 100;\r\n }\r\n if (angular.isUndefined(filterModel)) {\r\n params.filterModel = {}\r\n }\r\n if (!angular.isUndefined(sortModel)) {\r\n params.sortModel = sortModel;\r\n } else {\r\n params.sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n }\r\n return $http.post(\"internalapi/history/searches\", params).then(function (response) {\r\n return {\r\n searchRequests: response.data.content,\r\n totalRequests: response.data.totalElements\r\n }\r\n });\r\n }\r\n\r\n function formatRequest(request, includeIdLink, includequery, describeEmptySearch, includeTitle) {\r\n var result = [];\r\n result.push('Category: ' + request.categoryName);\r\n if (includequery && request.query) {\r\n result.push('Query: ' + request.query);\r\n }\r\n if (request.title && includeTitle) {\r\n result.push('Title: ' + request.title);\r\n } //Only include identifiers if title is unknown\r\n else if (request.identifiers.length > 0) {\r\n var href;\r\n var key;\r\n var value;\r\n var identifiers = _.indexBy(request.identifiers, 'identifierKey');\r\n if (\"IMDB\" in identifiers) {\r\n key = \"IMDB ID\";\r\n value = identifiers.IMDB.identifierValue;\r\n href = \"https://www.imdb.com/title/tt\" + value;\r\n } else if (\"TVDB\" in identifiers) {\r\n key = \"TVDB ID\";\r\n value = identifiers.TVDB.identifierValue;\r\n href = \"https://thetvdb.com/?tab=series&id=\" + value;\r\n } else if (\"TVRAGE\" in identifiers) {\r\n key = \"TVRage ID\";\r\n value = identifiers.TVRAGE.identifierValue;\r\n href = \"internalapi/redirect_rid?rid=\" + value;\r\n } else if (\"TMDB\" in identifiers) {\r\n key = \"TMDB ID\";\r\n value = identifiers.TMDB.identifierValue;\r\n href = \"https://www.themoviedb.org/movie/\" + value;\r\n }\r\n href = $filter(\"dereferer\")(href);\r\n if (includeIdLink) {\r\n result.push('' + key + ': ' + value + \"\");\r\n } else {\r\n result.push('' + key + \": \" + value);\r\n }\r\n }\r\n if (request.season) {\r\n result.push('Season: ' + request.season);\r\n }\r\n if (request.episode) {\r\n result.push('Episode: ' + request.episode);\r\n }\r\n if (request.author) {\r\n result.push('Author: ' + request.author);\r\n }\r\n if (result.length === 0 && describeEmptySearch) {\r\n result = ['Empty search'];\r\n }\r\n\r\n return result.join(\", \");\r\n\r\n }\r\n\r\n function getStateParamsForRepeatedSearch(request) {\r\n var stateParams = {};\r\n stateParams.mode = \"search\";\r\n var availableIdentifiers = _.pluck(request.identifiers, \"identifierKey\");\r\n if (availableIdentifiers.indexOf(\"TMDB\") > -1 || availableIdentifiers.indexOf(\"IMDB\") > -1) {\r\n stateParams.mode = \"movie\";\r\n } else if (availableIdentifiers.indexOf(\"TVRAGE\") > -1 || availableIdentifiers.indexOf(\"TVMAZE\") > -1 || availableIdentifiers.indexOf(\"TVDB\") > -1) {\r\n stateParams.mode = \"tvsearch\";\r\n }\r\n if (request.season) {\r\n stateParams.season = request.season;\r\n }\r\n if (request.episode) {\r\n stateParams.episode = request.episode;\r\n }\r\n\r\n _.each(request.identifiers, function (entry) {\r\n switch (entry.identifierKey) {\r\n case \"TMDB\":\r\n stateParams.tmdbId = entry.identifierValue;\r\n break;\r\n case \"IMDB\":\r\n stateParams.imdbId = entry.identifierValue;\r\n break;\r\n case \"TVMAZE\":\r\n stateParams.tvmazeId = entry.identifierValue;\r\n break;\r\n case \"TVRAGE\":\r\n stateParams.tvrageId = entry.identifierValue;\r\n break;\r\n case \"TVDB\":\r\n stateParams.tvdbId = entry.identifierValue;\r\n break;\r\n }\r\n });\r\n\r\n\r\n if (request.query !== \"\") {\r\n stateParams.query = request.query;\r\n }\r\n\r\n if (request.title) {\r\n stateParams.title = request.title;\r\n }\r\n\r\n if (request.categoryName) {\r\n stateParams.category = request.categoryName;\r\n }\r\n\r\n return stateParams;\r\n }\r\n\r\n\r\n}","\r\nSearchHistoryController.$inject = [\"$scope\", \"$state\", \"SearchHistoryService\", \"ConfigService\", \"localStorageService\", \"history\", \"$sce\", \"$filter\", \"$timeout\", \"$http\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .controller('SearchHistoryController', SearchHistoryController);\r\n\r\n\r\nfunction SearchHistoryController($scope, $state, SearchHistoryService, ConfigService, localStorageService, history, $sce, $filter, $timeout, $http, $uibModal) {\r\n $scope.limit = 100;\r\n $scope.pagination = {\r\n current: 1\r\n };\r\n var sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n $timeout(function () {\r\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\r\n }, 10);\r\n $scope.filterModel = {};\r\n\r\n //Filter options\r\n $scope.categoriesForFiltering = [];\r\n _.forEach(ConfigService.getSafe().categoriesConfig.categories, function (category) {\r\n $scope.categoriesForFiltering.push({label: category.name, id: category.name})\r\n });\r\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\r\n $scope.accessOptionsForFiltering = [{label: \"All\", value: \"all\"}, {label: \"API\", value: 'API'}, {\r\n label: \"Internal\",\r\n value: 'INTERNAL'\r\n }];\r\n\r\n //Preloaded data\r\n $scope.searchRequests = history.searchRequests;\r\n $scope.totalRequests = history.totalRequests;\r\n\r\n var anyUsername = false;\r\n var anyIp = false;\r\n for (var request of $scope.searchRequests) {\r\n if (request.username) {\r\n anyUsername = true;\r\n }\r\n if (request.ip) {\r\n anyIp = true;\r\n }\r\n if (anyIp && anyUsername) {\r\n break;\r\n }\r\n }\r\n\r\n $scope.foo = {\r\n showUserAgentInHistory: localStorageService.get(\"showUserAgentInHistory\") !== null ? localStorageService.get(\"showUserAgentInHistory\") : false\r\n };\r\n\r\n\r\n $scope.toggleShowUserAgentInHistory = function (value) {\r\n let doUpdateColumnSizes = value !== localStorageService.get(\"showUserAgentInHistory\");\r\n localStorageService.set(\"showUserAgentInHistory\", value);\r\n $scope.foo.showUserAgentInHistory = value;\r\n if (doUpdateColumnSizes) {\r\n setColumnSizes();\r\n }\r\n }\r\n\r\n function setColumnSizes() {\r\n $scope.columnSizes = {\r\n time: 10,\r\n query: 30,\r\n userAgent: 0,\r\n category: 10,\r\n additionalParameters: 22,\r\n source: 8,\r\n username: 10,\r\n ip: 10\r\n };\r\n if (ConfigService.getSafe().logging.historyUserInfoType === \"NONE\" || (!anyUsername && !anyIp)) {\r\n $scope.columnSizes.username = 0;\r\n $scope.columnSizes.ip = 0;\r\n $scope.columnSizes.query += 10;\r\n $scope.columnSizes.additionalParameters += 10;\r\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"IP\") {\r\n $scope.columnSizes.username = 0;\r\n $scope.columnSizes.query += 5;\r\n $scope.columnSizes.additionalParameters += 5;\r\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"USERNAME\") {\r\n $scope.columnSizes.ip = 0;\r\n $scope.columnSizes.query += 5;\r\n $scope.columnSizes.additionalParameters += 5;\r\n }\r\n if ($scope.foo.showUserAgentInHistory) {\r\n $scope.columnSizes.query -= 5;\r\n $scope.columnSizes.additionalParameters -= 5;\r\n $scope.columnSizes.userAgent = 10;\r\n }\r\n }\r\n\r\n setColumnSizes();\r\n\r\n\r\n $scope.update = function () {\r\n SearchHistoryService.getSearchHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (history) {\r\n $scope.searchRequests = history.searchRequests;\r\n $scope.totalRequests = history.totalRequests;\r\n });\r\n };\r\n\r\n $scope.$on(\"sort\", function (event, column, sortMode) {\r\n if (sortMode === 0) {\r\n sortModel = {\r\n column: \"time\",\r\n sortMode: 2\r\n };\r\n } else {\r\n sortModel = {\r\n column: column,\r\n sortMode: sortMode\r\n };\r\n }\r\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\r\n $scope.update();\r\n });\r\n\r\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\r\n if (filterModel.filterValue) {\r\n $scope.filterModel[column] = filterModel;\r\n } else {\r\n delete $scope.filterModel[column];\r\n }\r\n $scope.update();\r\n });\r\n\r\n\r\n $scope.openSearch = function (request) {\r\n $state.go(\"root.search\", SearchHistoryService.getStateParamsForRepeatedSearch(request), {\r\n inherit: false,\r\n notify: true,\r\n reload: true\r\n });\r\n };\r\n\r\n $scope.formatQuery = function (request) {\r\n if (request.title) {\r\n return request.title;\r\n }\r\n\r\n if (!request.query && request.identifiers.length === 0 && !request.season && !request.episode) {\r\n return \"Update query\";\r\n }\r\n return request.query;\r\n };\r\n\r\n $scope.formatAdditional = function (request) {\r\n var result = [];\r\n if (request.identifiers.length > 0) {\r\n var href;\r\n var key;\r\n var value;\r\n var pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TMDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TMDB ID\";\r\n href = \"https://www.themoviedb.org/movie/\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"IMDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"IMDB ID\";\r\n href = (\"https://www.imdb.com/title/tt\" + pair.identifierValue).replace(\"tttt\", \"tt\");\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVDB\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVDB ID\";\r\n href = \"https://thetvdb.com/?tab=series&id=\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVMAZE\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVMAZE ID\";\r\n href = \"https://www.tvmaze.com/shows/\" + pair.identifierValue;\r\n href = $filter(\"dereferer\")(href);\r\n value = pair.identifierValue;\r\n }\r\n\r\n pair = _.find(request.identifiers, function (pair) {\r\n return pair.identifierKey === \"TVRAGE\"\r\n });\r\n if (angular.isDefined(pair)) {\r\n key = \"TVRage ID\";\r\n href = \"internalapi/redirectRid/\" + pair.identifierValue;\r\n value = pair.identifierValue;\r\n }\r\n\r\n result.push(key + \": \" + '' + value + \"\");\r\n }\r\n if (request.season) {\r\n result.push(\"Season: \" + request.season);\r\n }\r\n if (request.episode) {\r\n result.push(\"Episode: \" + request.episode);\r\n }\r\n if (request.author) {\r\n result.push(\"Author: \" + request.author);\r\n }\r\n return $sce.trustAsHtml(result.join(\", \"));\r\n };\r\n\r\n $scope.showDetails = function (searchId) {\r\n\r\n ModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$http\", \"searchId\"];\r\n function ModalInstanceCtrl($scope, $uibModalInstance, $http, searchId) {\r\n $http.get(\"internalapi/history/searches/details/\" + searchId).then(function (response) {\r\n $scope.details = response.data;\r\n });\r\n }\r\n\r\n $uibModal.open({\r\n templateUrl: 'static/html/search-history-details-modal.html',\r\n controller: ModalInstanceCtrl,\r\n size: \"md\",\r\n resolve: {\r\n searchId: function () {\r\n return searchId;\r\n }\r\n }\r\n });\r\n\r\n\r\n }\r\n\r\n}\r\n\r\n","\r\nSearchController.$inject = [\"$scope\", \"$http\", \"$stateParams\", \"$state\", \"$uibModal\", \"$timeout\", \"$sce\", \"growl\", \"SearchService\", \"focus\", \"ConfigService\", \"HydraAuthService\", \"CategoriesService\", \"$element\", \"SearchHistoryService\"];\r\nSearchUpdateModalInstanceCtrl.$inject = [\"$scope\", \"$interval\", \"SearchService\", \"$uibModalInstance\", \"searchRequestId\", \"onCancel\", \"bootstrapped\"];angular\r\n .module('nzbhydraApp')\r\n .controller('SearchController', SearchController);\r\n\r\nfunction SearchController($scope, $http, $stateParams, $state, $uibModal, $timeout, $sce, growl, SearchService, focus, ConfigService, HydraAuthService, CategoriesService, $element, SearchHistoryService) {\r\n\r\n function getNumberOrUndefined(number) {\r\n if (_.isUndefined(number) || _.isNaN(number) || number === \"\") {\r\n return undefined;\r\n }\r\n number = parseInt(number);\r\n if (_.isNumber(number)) {\r\n return number;\r\n } else {\r\n return undefined;\r\n }\r\n }\r\n\r\n var searchRequestId = 0;\r\n var isSearchCancelled = false;\r\n var epochEnter;\r\n\r\n //Fill the form with the search values we got from the state params (so that their values are the same as in the current url)\r\n $scope.mode = $stateParams.mode;\r\n $scope.query = \"\";\r\n $scope.selectedItem = null;\r\n $scope.categories = _.filter(CategoriesService.getAllCategories(), function (c) {\r\n return c.mayBeSelected && !(c.ignoreResultsFrom === \"INTERNAL\" || c.ignoreResultsFrom === \"BOTH\");\r\n });\r\n $scope.minsize = getNumberOrUndefined($stateParams.minsize);\r\n $scope.maxsize = getNumberOrUndefined($stateParams.maxsize);\r\n if (angular.isDefined($stateParams.category) && $stateParams.category) {\r\n $scope.category = CategoriesService.getByName($stateParams.category);\r\n } else {\r\n $scope.category = CategoriesService.getDefault();\r\n $scope.minsize = $scope.category.minSizePreset;\r\n $scope.maxsize = $scope.category.maxSizePreset;\r\n }\r\n $scope.category = _.isNullOrEmpty($stateParams.category) ? CategoriesService.getDefault() : CategoriesService.getByName($stateParams.category);\r\n $scope.season = $stateParams.season;\r\n $scope.episode = $stateParams.episode;\r\n $scope.query = $stateParams.query;\r\n\r\n $scope.minage = getNumberOrUndefined($stateParams.minage);\r\n $scope.maxage = getNumberOrUndefined($stateParams.maxage);\r\n if (angular.isDefined($stateParams.indexers)) {\r\n $scope.indexers = decodeURIComponent($stateParams.indexers).split(\",\");\r\n }\r\n if (angular.isDefined($stateParams.title) || (angular.isDefined($stateParams.tmdbId) || angular.isDefined($stateParams.imdbId) || angular.isDefined($stateParams.tvmazeId) || angular.isDefined($stateParams.rid) || angular.isDefined($stateParams.tvdbId))) {\r\n var width = calculateWidth($stateParams.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n $scope.selectedItem = {\r\n tmdbId: $stateParams.tmdbId,\r\n imdbId: $stateParams.imdbId,\r\n tvmazeId: $stateParams.tvmazeId,\r\n rid: $stateParams.rid,\r\n tvdbId: $stateParams.tvdbId,\r\n title: $stateParams.title\r\n }\r\n }\r\n\r\n $scope.showIndexers = {};\r\n\r\n $scope.searchHistory = [];\r\n\r\n var safeConfig = ConfigService.getSafe();\r\n $scope.showIndexerSelection = HydraAuthService.getUserInfos().showIndexerSelection;\r\n\r\n\r\n $scope.typeAheadWait = 300;\r\n\r\n $scope.autocompleteLoading = false;\r\n $scope.isAskById = $scope.category.searchType === \"TVSEARCH\" || $scope.category.searchType === \"MOVIE\";\r\n $scope.isById = {value: $scope.selectedItem !== null || angular.isUndefined($scope.mode) || $scope.mode === null}; //If true the user wants to search by id so we enable autosearch. Was unable to achieve this using a simple boolean. Set to false if last search was not by ID\r\n $scope.availableIndexers = [];\r\n $scope.selectedIndexers = [];\r\n $scope.autocompleteClass = \"autocompletePosterMovies\";\r\n\r\n $scope.toggleCategory = function (searchCategory) {\r\n var oldCategory = $scope.category;\r\n $scope.category = searchCategory;\r\n\r\n //Show checkbox to ask if the user wants to search by ID (using autocomplete)\r\n if ($scope.category.searchType === \"TVSEARCH\" || $scope.category.searchType === \"MOVIE\") {\r\n $scope.isAskById = true;\r\n $scope.isById.value = true;\r\n } else {\r\n $scope.isAskById = false;\r\n $scope.isById.value = false;\r\n }\r\n\r\n if (oldCategory.searchType !== searchCategory.searchType) {\r\n $scope.selectedItem = null;\r\n }\r\n\r\n focus('searchfield');\r\n\r\n //Hacky way of triggering the autocomplete loading\r\n var searchModel = $element.find(\"#searchfield\").controller(\"ngModel\");\r\n if (angular.isDefined(searchModel.$viewValue)) {\r\n searchModel.$setViewValue(searchModel.$viewValue + \" \");\r\n }\r\n\r\n if (safeConfig.categoriesConfig.enableCategorySizes) {\r\n var min = searchCategory.minSizePreset;\r\n var max = searchCategory.maxSizePreset;\r\n if (_.isNumber(min)) {\r\n $scope.minsize = min;\r\n } else {\r\n $scope.minsize = \"\";\r\n }\r\n if (_.isNumber(max)) {\r\n $scope.maxsize = max;\r\n } else {\r\n $scope.maxsize = \"\";\r\n }\r\n }\r\n\r\n $scope.availableIndexers = getAvailableIndexers();\r\n };\r\n\r\n // Any function returning a promise object can be used to load values asynchronously\r\n $scope.getAutocomplete = function (val) {\r\n $scope.autocompleteLoading = true;\r\n //Expected model returned from API:\r\n //label: What to show in the results\r\n //title: Will be used for file search\r\n //value: Will be used as extraInfo (ttid oder tvdb id)\r\n //poster: url of poster to show\r\n\r\n //Don't use autocomplete if checkbox is disabled\r\n if (!$scope.isById.value || $scope.selectedItem) {\r\n return {};\r\n }\r\n\r\n if ($scope.category.searchType === \"MOVIE\") {\r\n return $http.get('internalapi/autocomplete/MOVIE', {params: {input: val}}).then(function (response) {\r\n $scope.autocompleteLoading = false;\r\n return response.data;\r\n });\r\n } else if ($scope.category.searchType === \"TVSEARCH\") {\r\n return $http.get('internalapi/autocomplete/TV', {params: {input: val}}).then(function (response) {\r\n $scope.autocompleteLoading = false;\r\n return response.data;\r\n });\r\n } else {\r\n return {};\r\n }\r\n };\r\n\r\n $scope.onTypeAheadEnter = function () {\r\n if (angular.isDefined(epochEnter)) {\r\n //Very hacky way of preventing a press of \"enter\" to select an autocomplete item from triggering a search\r\n //This is called *after* selectAutoComplete() is called\r\n var epochEnterNow = (new Date).getTime();\r\n var diff = epochEnterNow - epochEnter;\r\n if (diff > 50) {\r\n $scope.initiateSearch();\r\n }\r\n } else {\r\n $scope.initiateSearch();\r\n }\r\n };\r\n\r\n $scope.onTypeAheadKeyDown = function (event) {\r\n if (event.keyCode === 8) {\r\n if ($scope.query === \"\") {\r\n $scope.clearAutocomplete();\r\n }\r\n }\r\n };\r\n\r\n $scope.onDropOnQueryInput = function (event) {\r\n if ($scope.searchHistoryDragged === null || $scope.searchHistoryDragged === undefined) {\r\n return;\r\n }\r\n\r\n $scope.category = CategoriesService.getByName($scope.searchHistoryDragged.categoryName);\r\n $scope.season = $scope.searchHistoryDragged.season;\r\n $scope.episode = $scope.searchHistoryDragged.episode;\r\n $scope.query = $scope.searchHistoryDragged.query;\r\n\r\n if ($scope.searchHistoryDragged.title != null) {\r\n var width = calculateWidth($scope.searchHistoryDragged.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n }\r\n\r\n var tvmaze = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TVMAZE\"});\r\n var tmdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TMDB\"});\r\n var imdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"IMDB\"});\r\n var tvdb = _.findWhere($scope.searchHistoryDragged.identifiers, {identifierKey: \"TVDB\"});\r\n $scope.selectedItem = {\r\n tmdbId: tmdb === undefined ? null : tmdb.identifierValue,\r\n imdbId: imdb === undefined ? null : imdb.identifierValue,\r\n tvmazeId: tvmaze === undefined ? null : tvmaze.identifierValue,\r\n tvdbId: tvdb === undefined ? null : tvdb.identifierValue,\r\n title: $scope.searchHistoryDragged.title\r\n }\r\n\r\n event.preventDefault();\r\n\r\n $scope.searchHistoryDragged = null;\r\n focus('searchfield');\r\n $scope.status.isopen = false;\r\n }\r\n\r\n $scope.$on(\"searchHistoryDrag\", function (event, data) {\r\n $scope.searchHistoryDragged = JSON.parse(data);\r\n })\r\n\r\n //Is called when the search page is opened with params, either because the user initiated the search (which triggered a goTo to this page) or because a search URL was entered\r\n $scope.startSearch = function () {\r\n isSearchCancelled = false;\r\n searchRequestId = Math.round(Math.random() * 99999);\r\n var modalInstance = $scope.openModal(searchRequestId);\r\n\r\n var indexers = angular.isUndefined($scope.indexers) ? undefined : $scope.indexers.join(\",\");\r\n SearchService.search(searchRequestId, $scope.category.name, $scope.query, $scope.selectedItem, $scope.season, $scope.episode, $scope.minsize, $scope.maxsize, $scope.minage, $scope.maxage, indexers, $scope.mode).then(function () {\r\n //modalInstance.close();\r\n SearchService.setModalInstance(modalInstance);\r\n if (!isSearchCancelled) {\r\n $state.go(\"root.search.results\", {\r\n minsize: $scope.minsize,\r\n maxsize: $scope.maxsize,\r\n minage: $scope.minage,\r\n maxage: $scope.maxage\r\n }, {\r\n inherit: true\r\n });\r\n }\r\n },\r\n function () {\r\n modalInstance.close();\r\n });\r\n };\r\n\r\n $scope.openModal = function openModal(searchRequestId) {\r\n return $uibModal.open({\r\n templateUrl: 'static/html/search-state.html',\r\n controller: SearchUpdateModalInstanceCtrl,\r\n size: \"md\",\r\n backdrop: \"static\",\r\n backdropClass: \"waiting-cursor\",\r\n resolve: {\r\n searchRequestId: function () {\r\n return searchRequestId;\r\n },\r\n onCancel: function () {\r\n function cancel() {\r\n isSearchCancelled = true;\r\n }\r\n\r\n return cancel;\r\n }\r\n }\r\n });\r\n };\r\n\r\n $scope.goToSearchUrl = function () {\r\n //State params (query parameters) should all be lowercase\r\n var stateParams = {};\r\n stateParams.mode = $scope.category.searchType.toLowerCase();\r\n stateParams.imdbId = $scope.selectedItem === null ? null : $scope.selectedItem.imdbId;\r\n stateParams.tmdbId = $scope.selectedItem === null ? null : $scope.selectedItem.tmdbId;\r\n stateParams.tvdbId = $scope.selectedItem === null ? null : $scope.selectedItem.tvdbId;\r\n stateParams.tvrageId = $scope.selectedItem === null ? null : $scope.selectedItem.tvrageId;\r\n stateParams.tvmazeId = $scope.selectedItem === null ? null : $scope.selectedItem.tvmazeId;\r\n stateParams.title = $scope.selectedItem === null ? null : $scope.selectedItem.title;\r\n stateParams.season = $scope.season;\r\n stateParams.episode = $scope.episode;\r\n stateParams.query = $scope.query;\r\n stateParams.minsize = $scope.minsize;\r\n stateParams.maxsize = $scope.maxsize;\r\n stateParams.minage = $scope.minage;\r\n stateParams.maxage = $scope.maxage;\r\n stateParams.category = $scope.category.name;\r\n stateParams.indexers = encodeURIComponent($scope.selectedIndexers.join(\",\"));\r\n $state.go(\"root.search\", stateParams, {inherit: false, notify: true, reload: true});\r\n };\r\n\r\n $scope.repeatSearch = function (request) {\r\n var stateParams = SearchHistoryService.getStateParamsForRepeatedSearch(request);\r\n stateParams.indexers = encodeURIComponent($scope.selectedIndexers.join(\",\"));\r\n $state.go(\"root.search\", stateParams, {inherit: false, notify: true, reload: true});\r\n };\r\n\r\n $scope.searchBoxTooltip = \"Prefix terms with -- to exclude'\";\r\n $scope.$watchGroup(['isAskById', 'selectedItem'], function () {\r\n if (!$scope.isAskById) {\r\n $scope.searchBoxTooltip = \"Prefix terms with -- to exclude\";\r\n } else if ($scope.selectedItem === null) {\r\n $scope.searchBoxTooltip = \"Enter search terms for autocomplete\";\r\n } else {\r\n $scope.searchBoxTooltip = \"Enter additional search terms to limit the query\";\r\n }\r\n });\r\n\r\n $scope.clearAutocomplete = function () {\r\n $scope.selectedItem = null;\r\n $scope.query = \"\"; //Input is now for autocomplete and not for limiting the results\r\n focus('searchfield');\r\n };\r\n\r\n $scope.clearQuery = function () {\r\n $scope.selectedItem = null;\r\n $scope.query = \"\";\r\n focus('searchfield');\r\n };\r\n\r\n function calculateWidth(text) {\r\n var canvas = calculateWidth.canvas || (calculateWidth.canvas = document.createElement(\"canvas\"));\r\n var context = canvas.getContext(\"2d\");\r\n context.font = \"13px Roboto\";\r\n return context.measureText(text).width;\r\n }\r\n\r\n $scope.selectAutocompleteItem = function ($item) {\r\n $scope.selectedItem = $item;\r\n $scope.query = \"\";\r\n epochEnter = (new Date).getTime();\r\n var width = calculateWidth($item.title) + 30;\r\n $scope.selectedItemWidth = width + \"px\";\r\n };\r\n\r\n $scope.initiateSearch = function () {\r\n if ($scope.selectedIndexers.length === 0) {\r\n growl.error(\"You didn't select any indexers\");\r\n return;\r\n }\r\n if ($scope.selectedItem) {\r\n //Movie or tv show was selected\r\n $scope.goToSearchUrl();\r\n } else {\r\n //Simple query search\r\n $scope.goToSearchUrl();\r\n }\r\n };\r\n\r\n $scope.autocompleteActive = function () {\r\n return $scope.isAskById;\r\n };\r\n\r\n $scope.seriesSelected = function () {\r\n return $scope.category.searchType === \"TVSEARCH\";\r\n };\r\n\r\n $scope.toggleIndexer = function (indexer) {\r\n $scope.availableIndexers[indexer.name].activated = !$scope.availableIndexers[indexer.name].activated;\r\n };\r\n\r\n function isIndexerPreselected(indexer) {\r\n if (angular.isUndefined($scope.indexers)) {\r\n return indexer.preselect;\r\n } else {\r\n return _.contains($scope.indexers, indexer.name);\r\n }\r\n }\r\n\r\n function getAvailableIndexers() {\r\n var alreadySelected = $scope.selectedIndexers;\r\n var previouslyAvailable = _.pluck($scope.availableIndexers, \"name\");\r\n $scope.selectedIndexers = [];\r\n var availableIndexersList = _.chain(safeConfig.indexers).filter(function (indexer) {\r\n if (!indexer.showOnSearch) {\r\n return false;\r\n }\r\n var categorySelectedForIndexer = (angular.isUndefined(indexer.categories) || indexer.categories.length === 0 || $scope.category.name.toLowerCase() === \"all\" || indexer.categories.indexOf($scope.category.name) > -1);\r\n return categorySelectedForIndexer;\r\n }).sortBy(function (indexer) {\r\n return indexer.name.toLowerCase();\r\n })\r\n .map(function (indexer) {\r\n return {\r\n name: indexer.name,\r\n activated: isIndexerPreselected(indexer),\r\n preselect: indexer.preselect,\r\n categories: indexer.categories,\r\n searchModuleType: indexer.searchModuleType\r\n };\r\n }).value();\r\n _.forEach(availableIndexersList, function (x) {\r\n var deselectedBefore = (_.indexOf(previouslyAvailable, x.name) > -1 && _.indexOf(alreadySelected, x.name) === -1);\r\n var selectedBefore = (_.indexOf(previouslyAvailable, x.name) > -1 && _.indexOf(alreadySelected, x.name) > -1);\r\n if ((x.activated && !deselectedBefore) || selectedBefore) {\r\n $scope.selectedIndexers.push(x.name);\r\n }\r\n });\r\n return availableIndexersList;\r\n }\r\n\r\n\r\n $scope.formatRequest = function (request) {\r\n return $sce.trustAsHtml(SearchHistoryService.formatRequest(request, false, true, true, true));\r\n };\r\n\r\n $scope.availableIndexers = getAvailableIndexers();\r\n\r\n function getAndSetSearchRequests() {\r\n SearchHistoryService.getSearchHistoryForSearching().then(function (response) {\r\n $scope.searchHistory = response.searchRequests;\r\n });\r\n }\r\n\r\n if ($scope.mode) {\r\n $scope.startSearch();\r\n } else {\r\n //Getting the search history only makes sense when we're not currently searching\r\n _.defer(getAndSetSearchRequests);\r\n }\r\n\r\n $scope.$on(\"searchResultsShown\", function () {\r\n _.defer(getAndSetSearchRequests); //Defer because otherwise the results are only shown when this returns which may take a while with big databases\r\n });\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('SearchUpdateModalInstanceCtrl', SearchUpdateModalInstanceCtrl);\r\n\r\nfunction SearchUpdateModalInstanceCtrl($scope, $interval, SearchService, $uibModalInstance, searchRequestId, onCancel, bootstrapped) {\r\n\r\n var loggedSearchFinished = false;\r\n $scope.messages = [];\r\n $scope.indexerSelectionFinished = false;\r\n $scope.indexersSelected = 0;\r\n $scope.indexersFinished = 0;\r\n $scope.buttonText = \"Cancel\";\r\n $scope.buttonTooltip = \"Cancel search and return to search mask\";\r\n $scope.btnType = \"btn-danger\";\r\n\r\n var socket = new SockJS(bootstrapped.baseUrl + 'websocket');\r\n var stompClient = Stomp.over(socket);\r\n stompClient.debug = null;\r\n stompClient.connect({}, function (frame) {\r\n stompClient.subscribe('/topic/searchState', function (message) {\r\n var data = JSON.parse(message.body);\r\n if (searchRequestId !== data.searchRequestId) {\r\n return;\r\n }\r\n $scope.searchFinished = data.searchFinished;\r\n $scope.indexersSelected = data.indexersSelected;\r\n $scope.indexersFinished = data.indexersFinished;\r\n $scope.progressMax = data.indexersSelected;\r\n if ($scope.progressMax > data.indexersSelected) {\r\n $scope.progressMax = \">=\" + data.indexersSelected;\r\n }\r\n if ($scope.indexersFinished > 0) {\r\n $scope.buttonText = \"Show results\";\r\n $scope.buttonTooltip = \"Show results that have already been loaded\";\r\n $scope.btnType = \"btn-warning\";\r\n }\r\n if (data.messages) {\r\n $scope.messages = data.messages;\r\n }\r\n if ($scope.searchFinished && !loggedSearchFinished) {\r\n $scope.messages.push(\"Finished searching. Preparing results...\");\r\n loggedSearchFinished = true;\r\n }\r\n });\r\n });\r\n\r\n $scope.shortcutSearch = function () {\r\n SearchService.shortcutSearch(searchRequestId);\r\n // onCancel();\r\n // $uibModalInstance.dismiss();\r\n };\r\n\r\n $scope.hasResults = function (message) {\r\n return /^[^0]\\d+.*/.test(message);\r\n };\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').directive('draggable', ['$rootScope', function ($rootScope) {\r\n return {\r\n restrict: 'A',\r\n link: function (scope, el, attrs, controller) {\r\n\r\n el.bind(\"dragstart\", function (e) {\r\n $rootScope.$emit(\"searchHistoryDrag\", el.attr(\"data-request\"));\r\n $rootScope.$broadcast(\"searchHistoryDrag\", el.attr(\"data-request\"));\r\n });\r\n }\r\n }\r\n}]);\r\n\r\n","\r\nRestartService.$inject = [\"growl\", \"NzbHydraControlService\", \"$uibModal\"];\r\nRestartModalInstanceCtrl.$inject = [\"$scope\", \"$timeout\", \"$http\", \"$window\", \"RequestsErrorHandler\", \"message\", \"baseUrl\"];angular\r\n .module('nzbhydraApp')\r\n .factory('RestartService', RestartService);\r\n\r\nfunction RestartService(growl, NzbHydraControlService, $uibModal) {\r\n\r\n return {\r\n restart: restart,\r\n startCountdown: startCountdown\r\n };\r\n\r\n function restart(message) {\r\n NzbHydraControlService.restart().then(function (response) {\r\n startCountdown(message, response.data.message);\r\n }, function () {\r\n growl.info(\"Unable to send restart command.\");\r\n })\r\n }\r\n\r\n function startCountdown(message, baseUrl) {\r\n $uibModal.open({\r\n templateUrl: 'static/html/restart-modal.html',\r\n controller: RestartModalInstanceCtrl,\r\n size: \"md\",\r\n backdrop: 'static',\r\n keyboard: false,\r\n resolve: {\r\n message: function () {\r\n return message;\r\n },\r\n baseUrl: function () {\r\n return baseUrl;\r\n }\r\n }\r\n });\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('RestartModalInstanceCtrl', RestartModalInstanceCtrl);\r\n\r\nfunction RestartModalInstanceCtrl($scope, $timeout, $http, $window, RequestsErrorHandler, message, baseUrl) {\r\n\r\n message = (angular.isDefined(message) ? message : \"\");\r\n $scope.message = message + \"Will reload page when NZBHydra is back\";\r\n $scope.baseUrl = baseUrl;\r\n $scope.pingUrl = angular.isDefined(baseUrl) ? (baseUrl + \"/internalapi/control/ping\") : \"internalapi/control/ping\";\r\n\r\n $scope.internalCaR = function (message, timer) {\r\n if (timer === 45) {\r\n $scope.message = message + \" Restarting takes longer than expected. You might want to check the log to see what's going on.\";\r\n } else {\r\n $scope.message = message + \" Will reload page when NZBHydra is back.\";\r\n $timeout(function () {\r\n RequestsErrorHandler.specificallyHandled(function () {\r\n $http.get($scope.pingUrl, {ignoreLoadingBar: true}).then(\r\n function () {\r\n $timeout(function () {\r\n $scope.message = \"Reloading page...\";\r\n if (angular.isDefined($scope.baseUrl)) {\r\n $window.location.href = $scope.baseUrl;\r\n } else {\r\n $window.location.reload();\r\n }\r\n }, 2000); //Give Hydra some time to load in the background, it might return the ping but not be completely up yet\r\n }, function () {\r\n $scope.internalCaR(message, timer + 1);\r\n });\r\n });\r\n }, 1000);\r\n $scope.message = message + \" Will reload page when NZBHydra is back.\";\r\n }\r\n };\r\n\r\n //Wait three seconds because otherwise the currently running instance will be found\r\n $timeout(function () {\r\n $scope.internalCaR(message, 0);\r\n }, 3000)\r\n}","\r\nNzbHydraControlService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('NzbHydraControlService', NzbHydraControlService);\r\n\r\nfunction NzbHydraControlService($http) {\r\n\r\n return {\r\n restart: restart,\r\n shutdown: shutdown\r\n };\r\n\r\n function restart() {\r\n return $http.get(\"internalapi/control/restart\");\r\n }\r\n\r\n function shutdown() {\r\n return $http.get(\"internalapi/control/shutdown\");\r\n }\r\n\r\n}\r\n","\r\nNzbDownloadService.$inject = [\"$http\", \"ConfigService\", \"DownloaderCategoriesService\"];angular\r\n .module('nzbhydraApp')\r\n .factory('NzbDownloadService', NzbDownloadService);\r\n\r\nfunction NzbDownloadService($http, ConfigService, DownloaderCategoriesService) {\r\n\r\n var service = {\r\n download: download,\r\n getEnabledDownloaders: getEnabledDownloaders\r\n };\r\n\r\n return service;\r\n\r\n function sendNzbAddCommand(downloader, searchResults, category) {\r\n var params = {\r\n downloaderName: downloader.name,\r\n searchResults: searchResults,\r\n category: category\r\n };\r\n return $http.put(\"internalapi/downloader/addNzbs\", params);\r\n }\r\n\r\n function download(downloader, searchResults, alwaysAsk) {\r\n var category = downloader.defaultCategory;\r\n if (alwaysAsk || (_.isNullOrEmpty(category) && category !== \"Use original category\") && category !== \"Use mapped category\" && category !== \"Use no category\") {\r\n return DownloaderCategoriesService.openCategorySelection(downloader).then(function (category) {\r\n return sendNzbAddCommand(downloader, searchResults, category);\r\n }, function (result) {\r\n return result;\r\n });\r\n } else {\r\n return sendNzbAddCommand(downloader, searchResults, category)\r\n }\r\n }\r\n\r\n function getEnabledDownloaders() {\r\n return _.filter(ConfigService.getSafe().downloading.downloaders, \"enabled\");\r\n }\r\n}\r\n\r\n","\nNotificationService.$inject = [\"$http\"];angular\n .module('nzbhydraApp')\n .service('NotificationService', NotificationService);\n\nfunction NotificationService($http) {\n\n var eventTypesData = {\n AUTH_FAILURE: {\n readable: \"Auth failure\",\n titleTemplate: \"Auth failure\",\n bodyTemplate: \"NZBHydra: A login for username $username$ failed. IP: $ip$.\",\n templateHelp: \"Available variables: $username$, $ip$.\",\n messageType: \"FAILURE\"\n },\n RESULT_DOWNLOAD: {\n readable: \"NZB download\",\n titleTemplate: \"NZB download\",\n bodyTemplate: \"NZBHydra: The result \\\"$title$\\\" was grabbed from indexer $indexerName$.\",\n templateHelp: \"Available variables: $title, $indexerName$, $source$ (NZB or torrent), $age$ ([] for torrents).\",\n messageType: \"INFO\"\n },\n RESULT_DOWNLOAD_COMPLETION: {\n readable: \"Download completion\",\n titleTemplate: \"Download completion\",\n bodyTemplate: \"NZBHydra: Download of \\\"$title$\\\" has finished. Download result: $downloadResult$.\",\n templateHelp: \"Requires the downloading tool to be configured. Available variables: $title, $downloadResult$.\",\n messageType: \"INFO\"\n },\n INDEXER_DISABLED: {\n readable: \"Indexer disabled\",\n titleTemplate: \"Indexer disabled\",\n bodyTemplate: \"NZBHydra: Indexer $indexerName$ was disabled (state: $state$). Message:\\n$message$.\",\n templateHelp: \"Available variables: $indexerName$, $state$, $message$.\",\n messageType: \"WARNING\"\n },\n INDEXER_REENABLED: {\n readable: \"Indexer reenabled after error\",\n titleTemplate: \"Indexer reenabled after error\",\n bodyTemplate: \"NZBHydra: Indexer $indexerName$ was reenabled after a previous error. It had been disabled since $disabledAt$.\",\n templateHelp: \"Available variables: $indexerName$, $disabledAt$.\",\n messageType: \"SUCCESS\"\n },\n UPDATE_INSTALLED: {\n readable: \"Automatic update installed\",\n titleTemplate: \"Update installed\",\n bodyTemplate: \"NZBHydra: A new version of was installed: $version$\",\n templateHelp: \"Available variables: $version$.\",\n messageType: \"SUCCESS\"\n },\n VIP_RENEWAL_REQUIRED: {\n readable: \"VIP renewal required (14 day warning)\",\n titleTemplate: \"VIP renewal required\",\n bodyTemplate: \"NZBHydra: VIP access for indexer $indexerName$ will run out soon: $expirationDate$.\",\n templateHelp: \"Available variables: $indexerName$, $expirationDate$.\",\n messageType: \"WARNING\"\n }\n }\n\n this.getAllEventTypes = function () {\n return _.keys(eventTypesData);\n };\n\n this.getAllData = function () {\n return eventTypesData;\n };\n\n this.humanize = function (eventType) {\n return eventTypesData[eventType].readable;\n };\n\n this.getTemplateHelp = function (eventType) {\n return eventTypesData[eventType].templateHelp;\n };\n\n this.getTitleTemplate = function (eventType) {\n return eventTypesData[eventType].titleTemplate;\n };\n\n this.getBodyTemplate = function (eventType) {\n return eventTypesData[eventType].bodyTemplate;\n };\n\n this.testNotification = function (eventType) {\n return $http.get('internalapi/notifications/test/' + eventType);\n }\n\n\n}","\nNotificationHistoryController.$inject = [\"$scope\", \"StatsService\", \"preloadData\", \"ConfigService\", \"$timeout\", \"NotificationService\"];angular\n .module('nzbhydraApp')\n .controller('NotificationHistoryController', NotificationHistoryController);\n\n\nfunction NotificationHistoryController($scope, StatsService, preloadData, ConfigService, $timeout, NotificationService) {\n $scope.limit = 100;\n $scope.pagination = {\n current: 1\n };\n var sortModel = {\n column: \"time\",\n sortMode: 2\n };\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n }, 10);\n $scope.filterModel = {};\n\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\n\n\n //Preloaded data\n $scope.notifications = preloadData.notifications;\n $scope.totalNotifications = preloadData.totalNotifications;\n\n\n $scope.columnSizes = {\n time: 10,\n type: 15,\n title: 15,\n body: 40,\n urls: 20\n };\n\n $scope.update = function () {\n StatsService.getNotificationHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (data) {\n $scope.notifications = data.notifications;\n $scope.totalNotifications = data.totalNotifications;\n });\n };\n\n\n $scope.eventTypesForFiltering = [];\n var eventTypes = NotificationService.getAllEventTypes();\n _.each(eventTypes, function (key) {\n $scope.eventTypesForFiltering.push({label: NotificationService.humanize(key), id: key})\n })\n\n $scope.$on(\"sort\", function (event, column, sortMode) {\n if (sortMode === 0) {\n column = \"time\";\n sortMode = 2;\n }\n sortModel = {\n column: column,\n sortMode: sortMode\n };\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n $scope.update();\n });\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n $scope.update();\n })\n\n $scope.formatEventType = function (notification) {\n return NotificationService.humanize(notification.notificationEventType);\n };\n\n $scope.formatEventBody = function (notification) {\n return notification.body.replace(\"\\n\", \"
                      \");\n };\n\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateEpoch', reformatDateEpoch);\n\nfunction reformatDateEpoch() {\n return function (date) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm\");\n\n }\n}\n","\r\nModalService.$inject = [\"$uibModal\"];\r\nModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"headline\", \"message\", \"params\", \"textAlign\"];angular\r\n .module('nzbhydraApp')\r\n .factory('ModalService', ModalService);\r\n\r\nfunction ModalService($uibModal) {\r\n\r\n return {\r\n open: open\r\n };\r\n\r\n function open(headline, message, params, size, textAlign) {\r\n //params example:\r\n /*\r\n var p =\r\n {\r\n yes: {\r\n text: \"Yes\", //default: Ok\r\n onYes: function() {}\r\n },\r\n no: { //default: Empty\r\n text: \"No\",\r\n onNo: function () {\r\n }\r\n },\r\n cancel: {\r\n text: \"Cancel\", //default: Cancel\r\n onCancel: function () {\r\n }\r\n }\r\n };\r\n */\r\n if (angular.isUndefined(textAlign)) {\r\n textAlign = \"center\";\r\n }\r\n var modalInstance = $uibModal.open({\r\n templateUrl: 'static/html/modal.html',\r\n controller: 'ModalInstanceCtrl',\r\n size: angular.isDefined(size) ? size : \"md\",\r\n resolve: {\r\n headline: function () {\r\n return headline;\r\n },\r\n message: function () {\r\n return message;\r\n },\r\n params: function () {\r\n return params;\r\n },\r\n textAlign: function () {\r\n return textAlign;\r\n }\r\n }\r\n });\r\n\r\n modalInstance.result.then(function () {\r\n\r\n }, function () {\r\n\r\n });\r\n }\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp')\r\n .controller('ModalInstanceCtrl', ModalInstanceCtrl);\r\n\r\nfunction ModalInstanceCtrl($scope, $uibModalInstance, headline, message, params, textAlign) {\r\n\r\n $scope.message = message;\r\n $scope.headline = headline;\r\n $scope.params = params;\r\n $scope.showCancel = angular.isDefined(params) && angular.isDefined(params.cancel);\r\n $scope.showNo = angular.isDefined(params) && angular.isDefined(params.no);\r\n $scope.textAlign = textAlign;\r\n\r\n if (angular.isUndefined(params) || angular.isUndefined(params.yes)) {\r\n $scope.params = {\r\n yes: {\r\n text: \"Ok\"\r\n }\r\n }\r\n } else if (angular.isUndefined(params.yes.text)) {\r\n params.yes.text = \"Yes\";\r\n }\r\n\r\n if (angular.isDefined(params) && angular.isDefined(params.no) && angular.isUndefined($scope.params.no.text)) {\r\n $scope.params.no.text = \"No\";\r\n }\r\n\r\n if (angular.isDefined(params) && angular.isDefined(params.cancel) && angular.isUndefined($scope.params.cancel.text)) {\r\n $scope.params.cancel.text = \"Cancel\";\r\n }\r\n\r\n $scope.yes = function () {\r\n $uibModalInstance.close();\r\n if (angular.isDefined(params) && angular.isDefined(params.yes) && angular.isDefined($scope.params.yes.onYes)) {\r\n $scope.params.yes.onYes();\r\n }\r\n };\r\n\r\n $scope.no = function () {\r\n $uibModalInstance.close();\r\n if (angular.isDefined(params) && angular.isDefined(params.no) && angular.isDefined($scope.params.no.onNo)) {\r\n $scope.params.no.onNo($uibModalInstance);\r\n }\r\n };\r\n\r\n $scope.cancel = function () {\r\n $uibModalInstance.dismiss();\r\n if (angular.isDefined(params.cancel) && angular.isDefined($scope.params.cancel.onCancel)) {\r\n $scope.params.cancel.onCancel();\r\n }\r\n };\r\n\r\n $scope.$on(\"modal.closing\", function (targetScope, reason, c) {\r\n if (reason == \"backdrop click\") {\r\n $scope.cancel();\r\n }\r\n });\r\n}\r\n","angular\n .module('nzbhydraApp')\n .service('GeneralModalService', GeneralModalService);\n\nfunction GeneralModalService() {\n\n\n this.open = function (msg, template, templateUrl, size, data) {\n\n //Prevent circular dependency\n var myInjector = angular.injector([\"ng\", \"ui.bootstrap\"]);\n var $uibModal = myInjector.get(\"$uibModal\");\n var params = {};\n\n if (angular.isUndefined(size)) {\n params[\"size\"] = size;\n }\n if (angular.isUndefined(template)) {\n if (angular.isUndefined(templateUrl)) {\n params[\"template\"] = '
                      ' + msg + '
                      ';\n } else {\n params[\"templateUrl\"] = templateUrl;\n }\n } else {\n params[\"template\"] = template;\n }\n params[\"resolve\"] =\n {\n data: function () {\n return data;\n }\n };\n\n var modalInstance = $uibModal.open(params);\n\n modalInstance.result.then();\n\n };\n\n\n}","\nMigrationService.$inject = [\"$uibModal\"];\nMigrationModalInstanceCtrl.$inject = [\"$scope\", \"$uibModalInstance\", \"$interval\", \"$http\", \"blockUI\", \"ModalService\"];angular\n .module('nzbhydraApp')\n .factory('MigrationService', MigrationService);\n\nfunction MigrationService($uibModal) {\n\n return {\n migrate: migrate\n };\n\n function migrate() {\n var modalInstance = $uibModal.open({\n templateUrl: 'static/html/migration-modal.html',\n controller: 'MigrationModalInstanceCtrl',\n size: \"md\",\n backdrop: 'static',\n keyboard: false\n });\n\n modalInstance.result.then(function () {\n ConfigService.reloadConfig();\n }, function () {\n });\n }\n}\n\nangular\n .module('nzbhydraApp')\n .controller('MigrationModalInstanceCtrl', MigrationModalInstanceCtrl);\n\nfunction MigrationModalInstanceCtrl($scope, $uibModalInstance, $interval, $http, blockUI, ModalService) {\n\n $scope.baseUrl = \"http://127.0.0.1:5075\";\n\n $scope.foo = {isMigrating: false, baseUrl: $scope.baseUrl};\n $scope.doMigrateDatabase = true;\n\n $scope.yes = function () {\n var params;\n var url;\n if ($scope.foo.baseUrl && $scope.foo.isFileBasedOpen) {\n $scope.foo.baseUrl = null;\n }\n\n\n if ($scope.foo.isUrlBasedOpen) {\n url = \"internalapi/migration/url\";\n params = {baseurl: $scope.foo.baseUrl, doMigrateDatabase: $scope.doMigrateDatabase};\n if (!params.baseurl) {\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"You did not enter a URL\", {\n yes: {\n text: \"OK\"\n }\n });\n return;\n }\n } else {\n url = \"internalapi/migration/files\";\n params = {\n settingsCfgFile: $scope.foo.settingsCfgFile,\n dbFile: $scope.foo.nzbhydraDbFile,\n doMigrateDatabase: $scope.doMigrateDatabase\n };\n if (!params.settingsCfgFile || (!params.dbFile && params.doMigrateDatabase)) {\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"You did not enter all required valued\", {\n yes: {\n text: \"OK\"\n }\n });\n return;\n }\n }\n\n $scope.foo.isMigrating = true;\n\n var updateMigrationMessagesInterval = $interval(function () {\n $http.get(\"internalapi/migration/messages\").then(function (response) {\n $scope.foo.messages = response.data;\n },\n function () {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n }\n );\n }, 500);\n\n $http.get(url, {params: params}).then(function (response) {\n var message;\n blockUI.stop();\n var data = response.data;\n if (!data.requirementsMet) {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n ModalService.open(\"Requirements not met\", \"An error occurred while preparing the migration:
                      \" + data.error, {\n yes: {\n text: \"OK\"\n }\n });\n } else if (!data.configMigrated) {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n ModalService.open(\"Config migration failed\", \"An error occurred while migrating the config. Migration failed:
                      \" + data.error, {\n yes: {\n text: \"OK\"\n }\n });\n } else if (!data.databaseMigrated) {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n message = \"An error occurred while migrating the database.
                      \" + data.error + \"
                      . The config was migrated successfully though.\";\n if (data.messages.length > 0) {\n message += '

                      The following warnings resulted from the config migration:
                        ';\n _.forEach(data.messages, function (msg) {\n message += \"
                      • \" + msg + \"
                      • \";\n });\n message += \"
                      \";\n }\n ModalService.open(\"Database migration failed\", message, {\n yes: {\n text: \"OK\"\n }\n });\n } else {\n $interval.cancel(updateMigrationMessagesInterval);\n $uibModalInstance.dismiss();\n $scope.foo.isMigrating = false;\n message = \"The migration was completed successfully.\";\n if (data.warningMessages.length > 0) {\n message += '

                      The following warnings resulted from the config migration:
                        ';\n _.forEach(data.warningMessages, function (msg) {\n message += \"
                      • \" + msg + \"
                      • \";\n });\n message += \"
                      \";\n }\n message += \"

                      NZBHydra needs to restart for the changes to be effective.\";\n ModalService.open(\"Migration successful\", message, {\n yes: {\n onYes: function () {\n RestartService.restart();\n },\n text: \"Restart\"\n },\n cancel: {\n onCancel: function () {\n\n },\n text: \"Not now\"\n }\n });\n }\n }, function (response) {\n $interval.cancel(updateMigrationMessagesInterval);\n $scope.foo.isMigrating = false;\n $scope.foo.messages = [response.data.message];\n }\n );\n\n $scope.$on('$destroy', function () {\n if (angular.isDefined(updateMigrationMessagesInterval)) {\n $interval.cancel(updateMigrationMessagesInterval);\n }\n });\n\n };\n\n $scope.cancel = function () {\n $uibModalInstance.dismiss();\n };\n\n}\n","\r\nLoginController.$inject = [\"$scope\", \"RequestsErrorHandler\", \"$state\", \"HydraAuthService\", \"growl\"];angular\r\n .module('nzbhydraApp')\r\n .controller('LoginController', LoginController);\r\n\r\nfunction LoginController($scope, RequestsErrorHandler, $state, HydraAuthService, growl) {\r\n $scope.user = {};\r\n $scope.login = function () {\r\n RequestsErrorHandler.specificallyHandled(function () {\r\n HydraAuthService.login($scope.user.username, $scope.user.password).then(function () {\r\n HydraAuthService.setLoggedInByForm();\r\n growl.info(\"Login successful!\");\r\n $state.go(\"root.search\");\r\n }, function () {\r\n growl.error(\"Login failed!\")\r\n });\r\n });\r\n }\r\n}\r\n","\nIndexerStatusesController.$inject = [\"$scope\", \"$http\", \"statuses\"];\nformatDate.$inject = [\"dateFilter\"];angular\n .module('nzbhydraApp')\n .controller('IndexerStatusesController', IndexerStatusesController);\n\nfunction IndexerStatusesController($scope, $http, statuses) {\n $scope.statuses = statuses.data;\n $scope.expiryWarnings = {};\n\n $scope.formatState = function (state) {\n if (state === \"ENABLED\") {\n return \"Enabled\";\n } else if (state === \"DISABLED_SYSTEM_TEMPORARY\") {\n return \"Temporarily disabled by system\";\n } else if (state === \"DISABLED_SYSTEM\") {\n return \"Disabled by system\";\n } else {\n return \"Disabled by user\";\n }\n };\n\n $scope.getLabelClass = function (state) {\n if (state === \"ENABLED\") {\n return \"primary\";\n } else if (state === \"DISABLED_SYSTEM_TEMPORARY\") {\n return \"warning\";\n } else if (state === \"DISABLED_SYSTEM\") {\n return \"danger\";\n } else {\n return \"default\";\n }\n };\n\n $scope.isInPast = function (epochSeconds) {\n return epochSeconds < moment().unix();\n };\n\n\n _.each($scope.statuses, function (status) {\n if (status.vipExpirationDate != null && status.vipExpirationDate !== \"Lifetime\") {\n var expiryDate = moment(status.vipExpirationDate, \"YYYY-MM-DD\");\n var messagePrefix = \"VIP access\";\n if (expiryDate < moment()) {\n status.expiryWarning = messagePrefix + \" expired\";\n } else if (expiryDate.subtract(7, 'days') < moment()) {\n status.expiryWarning = messagePrefix + \" will expire in the next 7 days\";\n }\n console.log(status.expiryWarning);\n }\n }\n )\n ;\n}\n\nangular\n .module('nzbhydraApp')\n .filter('formatDate', formatDate);\n\nfunction formatDate(dateFilter) {\n return function (timestamp, hidePast) {\n if (timestamp) {\n if (timestamp * 1000 < (new Date).getTime() && hidePast) {\n return \"\"; //\n }\n\n var t = timestamp * 1000;\n t = dateFilter(t, 'yyyy-MM-dd HH:mm');\n return t;\n } else {\n return \"\";\n }\n }\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDate', reformatDate);\n\nfunction reformatDate() {\n return function (date, format) {\n if (!date) {\n return \"\";\n }\n if (angular.isUndefined(format)) {\n format = \"YYYY-MM-DD HH:mm\";\n }\n //Date in database is saved as UTC without timezone information\n return moment.unix(date).local().format(format);\n }\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateSeconds', reformatDateSeconds);\n\nfunction reformatDateSeconds() {\n return function (date, format) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm:ss\");\n }\n}\n\n\nangular\n .module('nzbhydraApp')\n .filter('humanizeDate', humanizeDate);\n\nfunction humanizeDate() {\n return function (date) {\n return moment().to(moment.unix(date));\n }\n}","\r\nIndexController.$inject = [\"$scope\", \"$http\", \"$stateParams\", \"$state\"];angular\r\n .module('nzbhydraApp')\r\n .controller('IndexController', IndexController);\r\n\r\nfunction IndexController($scope, $http, $stateParams, $state) {\r\n\r\n $state.go(\"root.search\");\r\n}\r\n","\r\nHydraAuthService.$inject = [\"$q\", \"$rootScope\", \"$http\", \"bootstrapped\", \"$httpParamSerializerJQLike\", \"$state\"];angular\r\n .module('nzbhydraApp')\r\n .factory('HydraAuthService', HydraAuthService);\r\n\r\nfunction HydraAuthService($q, $rootScope, $http, bootstrapped, $httpParamSerializerJQLike, $state) {\r\n\r\n var loggedIn = bootstrapped.username;\r\n\r\n\r\n return {\r\n isLoggedIn: isLoggedIn,\r\n login: login,\r\n askForPassword: askForPassword,\r\n logout: logout,\r\n setLoggedInByForm: setLoggedInByForm,\r\n getUserRights: getUserRights,\r\n setLoggedInByBasic: setLoggedInByBasic,\r\n getUserName: getUserName,\r\n getUserInfos: getUserInfos\r\n };\r\n\r\n function getUserInfos() {\r\n return bootstrapped;\r\n }\r\n\r\n function isLoggedIn() {\r\n return bootstrapped.username;\r\n }\r\n\r\n function setLoggedInByForm() {\r\n $rootScope.$broadcast(\"user:loggedIn\");\r\n }\r\n\r\n\r\n function setLoggedInByBasic(_maySeeStats, _maySeeAdmin, _username) {\r\n }\r\n\r\n function login(username, password) {\r\n var deferred = $q.defer();\r\n //return $http.post(\"login\", data = {username: username, password: password})\r\n return $http({\r\n url: \"login\",\r\n method: \"POST\",\r\n headers: {\r\n 'Content-Type': 'application/x-www-form-urlencoded' // Note the appropriate header\r\n },\r\n data: $httpParamSerializerJQLike({username: username, password: password})\r\n })\r\n .then(function () {\r\n $http.get(\"internalapi/userinfos\").then(function (data) {\r\n bootstrapped = data.data;\r\n loggedIn = true;\r\n $rootScope.$broadcast(\"user:loggedIn\");\r\n deferred.resolve();\r\n });\r\n });\r\n }\r\n\r\n function askForPassword(params) {\r\n return $http.get(\"internalapi/askpassword\", {params: params}).then(function (data) {\r\n bootstrapped = data.data;\r\n return bootstrapped;\r\n });\r\n }\r\n\r\n function logout() {\r\n var deferred = $q.defer();\r\n return $http.post(\"logout\").then(function () {\r\n $http.get(\"internalapi/userinfos\").then(function (data) {\r\n bootstrapped = data.data;\r\n $rootScope.$broadcast(\"user:loggedOut\");\r\n loggedIn = false;\r\n if (bootstrapped.maySeeSearch) {\r\n $state.go(\"root.search\");\r\n } else {\r\n $state.go(\"root.login\");\r\n }\r\n //window.location.reload(false);\r\n deferred.resolve();\r\n });\r\n });\r\n }\r\n\r\n function getUserRights() {\r\n var userInfos = getUserInfos();\r\n return {\r\n maySeeStats: userInfos.maySeeStats,\r\n maySeeAdmin: userInfos.maySeeAdmin,\r\n maySeeSearch: userInfos.maySeeSearch\r\n };\r\n }\r\n\r\n function getUserName() {\r\n return bootstrapped.username;\r\n }\r\n\r\n\r\n}","\nHeaderController.$inject = [\"$scope\", \"$state\", \"growl\", \"HydraAuthService\", \"bootstrapped\"];angular\n .module('nzbhydraApp')\n .controller('HeaderController', HeaderController);\n\nfunction HeaderController($scope, $state, growl, HydraAuthService, bootstrapped) {\n\n\n $scope.showLoginout = false;\n $scope.oldUserName = null;\n $scope.bootstrapped = bootstrapped;\n\n function update(event) {\n\n $scope.userInfos = HydraAuthService.getUserInfos();\n if (!$scope.userInfos.authConfigured) {\n $scope.showSearch = true;\n $scope.showAdmin = true;\n $scope.showStats = true;\n $scope.showLoginout = false;\n } else {\n if ($scope.userInfos.username) {\n $scope.showSearch = true;\n $scope.showAdmin = $scope.userInfos.maySeeAdmin || !$scope.userInfos.adminRestricted;\n $scope.showStats = $scope.userInfos.maySeeStats || !$scope.userInfos.statsRestricted;\n $scope.showLoginout = true;\n $scope.username = $scope.userInfos.username;\n $scope.loginlogoutText = \"Logout \" + $scope.username;\n $scope.oldUserName = $scope.username;\n } else {\n $scope.showAdmin = !$scope.userInfos.adminRestricted;\n $scope.showStats = !$scope.userInfos.statsRestricted;\n $scope.showSearch = !$scope.userInfos.searchRestricted;\n $scope.loginlogoutText = \"Login\";\n $scope.showLoginout = ($scope.userInfos.adminRestricted || $scope.userInfos.statsRestricted || $scope.userInfos.searchRestricted) && event !== \"loggedOut\" && !$state.is(\"root.login\");\n $scope.username = \"\";\n }\n }\n }\n\n update();\n\n\n $scope.$on(\"user:loggedIn\", function (event, data) {\n update(\"loggedIn\");\n });\n\n $scope.$on(\"user:loggedOut\", function (event, data) {\n update(\"loggedOut\");\n });\n\n $scope.loginout = function () {\n if (HydraAuthService.isLoggedIn()) {\n HydraAuthService.logout().then(function () {\n if ($scope.userInfos.authType === \"BASIC\") {\n growl.info(\"Logged out. Close your browser to make sure session is closed.\");\n }\n else if ($scope.userInfos.authType === \"FORM\") {\n growl.info(\"Logged out\");\n }\n update();\n //$state.go(\"root.search\", null, {reload: true});\n });\n\n } else {\n if ($scope.userInfos.authType === \"BASIC\") {\n var params = {};\n if ($scope.oldUserName) {\n params = {\n old_username: $scope.oldUserName\n }\n }\n HydraAuthService.askForPassword(params).then(function () {\n growl.info(\"Login successful!\");\n $scope.oldUserName = null;\n update(\"loggedIn\");\n $state.go(\"root.search\");\n })\n } else if ($scope.userInfos.authType === \"FORM\") {\n $state.go(\"root.login\");\n } else {\n growl.info(\"You shouldn't need to login but here you go!\");\n }\n }\n\n };\n}\n","/*\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n//\nGenericStorageService.$inject = [\"$http\"];\nangular\n .module('nzbhydraApp')\n .factory('GenericStorageService', GenericStorageService);\n\nfunction GenericStorageService($http) {\n\n return {\n get: get,\n put: put\n };\n\n function get(key, forUser) {\n return $http.get(\"internalapi/genericstorage/\" + key, {params: {forUser: forUser}, ignoreLoadingBar: true});\n }\n\n function put(key, forUser, value) {\n return $http.put(\"internalapi/genericstorage/\" + key, value, {params: {forUser: forUser}, ignoreLoadingBar: true});\n }\n\n\n}","var HEADER_NAME = 'NzbHydra2-Handle-Errors-Generically';\nvar specificallyHandleInProgress = false;\n\nnzbhydraapp.factory('RequestsErrorHandler', [\"$q\", \"growl\", \"blockUI\", \"GeneralModalService\", function ($q, growl, blockUI, GeneralModalService) {\n return {\n // --- The user's API for claiming responsiblity for requests ---\n specificallyHandled: function (specificallyHandledBlock) {\n specificallyHandleInProgress = true;\n try {\n return specificallyHandledBlock();\n } finally {\n specificallyHandleInProgress = false;\n }\n },\n\n // --- Response interceptor for handling errors generically ---\n responseError: function (rejection) {\n blockUI.reset();\n if (rejection.data instanceof ArrayBuffer) {\n //The case when the response was specifically requested as that, e.g. for debug infos\n rejection.data = JSON.parse(new TextDecoder().decode(rejection.data));\n }\n var shouldHandle = (rejection && rejection.config && rejection.status !== 403 && rejection.config.headers && rejection.config.headers[HEADER_NAME] && !rejection.config.url.contains(\"logerror\") && !rejection.config.url.contains(\"/ping\") && !rejection.config.alreadyHandled);\n if (shouldHandle) {\n if (rejection.data) {\n\n var message = \"An error occurred:
                      \" + rejection.data.status;\n if (rejection.data.error) {\n message += \": \" + rejection.data.error\n }\n if (rejection.data.path) {\n message += \"

                      Path: \" + rejection.data.path;\n }\n if (message !== \"No message available\") {\n message += \"

                      Message: \" + _.escape(rejection.data.message);\n } else {\n message += \"

                      Exception: \" + rejection.data.exception;\n }\n } else {\n message = \"An unknown error occurred while communicating with NZBHydra:

                      \" + JSON.stringify(rejection);\n }\n GeneralModalService.open(message);\n\n } else if (rejection && rejection.config && rejection.config.headers && rejection.config.headers[HEADER_NAME] && rejection.config.url.contains(\"logerror\")) {\n console.log(\"Not handling connection error while sending exception to server\");\n }\n return $q.reject(rejection);\n }\n };\n}]);\n\nnzbhydraapp.config(['$provide', '$httpProvider', function ($provide, $httpProvider) {\n $httpProvider.interceptors.push('RequestsErrorHandler');\n\n // --- Decorate $http to add a special header by default ---\n\n function addHeaderToConfig(config) {\n config = config || {};\n config.headers = config.headers || {};\n\n // Add the header unless user asked to handle errors himself\n if (!specificallyHandleInProgress) {\n config.headers[HEADER_NAME] = true;\n }\n\n return config;\n }\n\n // The rest here is mostly boilerplate needed to decorate $http safely\n $provide.decorator('$http', ['$delegate', function ($delegate) {\n function decorateRegularCall(method) {\n return function (url, config) {\n return $delegate[method](url, addHeaderToConfig(config));\n };\n }\n\n function decorateDataCall(method) {\n return function (url, data, config) {\n return $delegate[method](url, data, addHeaderToConfig(config));\n };\n }\n\n function copyNotOverriddenAttributes(newHttp) {\n for (var attr in $delegate) {\n if (!newHttp.hasOwnProperty(attr)) {\n if (typeof($delegate[attr]) === 'function') {\n newHttp[attr] = function () {\n return $delegate.apply($delegate, arguments);\n };\n } else {\n newHttp[attr] = $delegate[attr];\n }\n }\n }\n }\n\n var newHttp = function (config) {\n return $delegate(addHeaderToConfig(config));\n };\n\n newHttp.get = decorateRegularCall('get');\n newHttp.delete = decorateRegularCall('delete');\n newHttp.head = decorateRegularCall('head');\n newHttp.jsonp = decorateRegularCall('jsonp');\n newHttp.post = decorateDataCall('post');\n newHttp.put = decorateDataCall('put');\n\n copyNotOverriddenAttributes(newHttp);\n\n return newHttp;\n }]);\n}]);\n","var filters = angular.module('filters', []);\n\nfilters.filter('bytes', function () {\n return function (bytes) {\n return filesize(bytes);\n }\n});\n\nfilters\n .filter('unsafe', ['$sce', function ($sce) {\n return function (text) {\n return $sce.trustAsHtml(text);\n };\n }]);\n\n","\r\nFileSelectionService.$inject = [\"$http\", \"$q\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .factory('FileSelectionService', FileSelectionService);\r\n\r\nfunction FileSelectionService($http, $q, $uibModal) {\r\n\r\n var categories = {};\r\n var selectedCategory = {};\r\n\r\n var service = {\r\n open: open\r\n };\r\n\r\n var deferred;\r\n\r\n return service;\r\n\r\n\r\n function open(fullPath, type) {\r\n var instance = $uibModal.open({\r\n templateUrl: 'static/html/file-selection.html',\r\n controller: 'FileSelectionModalController',\r\n size: \"md\",\r\n resolve: {\r\n data: function () {\r\n return $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: angular.isDefined(fullPath) ? fullPath : null,\r\n goUp: false,\r\n type: type\r\n });\r\n },\r\n type: function () {\r\n return type;\r\n }\r\n }\r\n });\r\n\r\n instance.result.then(function (selection) {\r\n deferred.resolve(selection);\r\n }, function () {\r\n deferred.reject(\"dismissed\");\r\n }\r\n );\r\n deferred = $q.defer();\r\n return deferred.promise;\r\n }\r\n\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').controller('FileSelectionModalController', [\"$scope\", \"$http\", \"$uibModalInstance\", \"FileSelectionService\", \"data\", \"type\", function ($scope, $http, $uibModalInstance, FileSelectionService, data, type) {\r\n\r\n $scope.type = type;\r\n $scope.showType = type === \"file\" ? \"File\" : \"Folder\";\r\n $scope.data = data.data;\r\n\r\n $scope.select = function (fileOrFolder, selectType) {\r\n if (selectType === \"file\" && type === \"file\") {\r\n $uibModalInstance.close(fileOrFolder.fullPath);\r\n } else if (selectType === \"folder\") {\r\n $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: fileOrFolder.fullPath,\r\n type: type,\r\n goUp: false\r\n }).then(function (data) {\r\n $scope.data = data.data;\r\n })\r\n }\r\n };\r\n\r\n $scope.goUp = function () {\r\n $http.post(\"internalapi/config/folderlisting\", {\r\n fullPath: $scope.data.fullPath,\r\n type: type,\r\n goUp: true\r\n }).then(function (data) {\r\n $scope.data = data.data;\r\n })\r\n };\r\n\r\n $scope.submit = function () {\r\n $uibModalInstance.close($scope.data.fullPath);\r\n }\r\n\r\n}]);","\r\nFileDownloadService.$inject = [\"$http\", \"growl\"];angular\r\n .module('nzbhydraApp')\r\n .factory('FileDownloadService', FileDownloadService);\r\n\r\nfunction FileDownloadService($http, growl) {\r\n\r\n var service = {\r\n downloadFile: downloadFile\r\n };\r\n\r\n return service;\r\n\r\n function downloadFile(link, filename, method, data) {\r\n return $http({\r\n method: method,\r\n url: link,\r\n data: data,\r\n responseType: 'arraybuffer'\r\n }).then(function (response, status, headers, config) {\r\n var a = document.createElement('a');\r\n var blob = new Blob([response.data], {'type': \"application/octet-stream\"});\r\n a.href = URL.createObjectURL(blob);\r\n a.download = filename;\r\n\r\n document.body.appendChild(a);\r\n a.click();\r\n document.body.removeChild(a);\r\n }, function (data, status, headers, config) {\r\n growl.error(status);\r\n });\r\n\r\n }\r\n\r\n\r\n}\r\n\r\n","\r\nDownloaderCategoriesService.$inject = [\"$http\", \"$q\", \"$uibModal\"];angular\r\n .module('nzbhydraApp')\r\n .factory('DownloaderCategoriesService', DownloaderCategoriesService);\r\n\r\nfunction DownloaderCategoriesService($http, $q, $uibModal) {\r\n\r\n var categories = {};\r\n var selectedCategory = {};\r\n\r\n var service = {\r\n get: getCategories,\r\n invalidate: invalidate,\r\n select: select,\r\n openCategorySelection: openCategorySelection\r\n };\r\n\r\n var deferred;\r\n\r\n return service;\r\n\r\n function getCategories(downloader) {\r\n function loadAll() {\r\n if (downloader.name in categories) {\r\n var deferred = $q.defer();\r\n deferred.resolve(categories[downloader.name]);\r\n return deferred.promise;\r\n }\r\n\r\n return $http.get(encodeURI('internalapi/downloader/' + downloader.name + \"/categories\"))\r\n .then(function (categoriesResponse) {\r\n categories[downloader.name] = categoriesResponse.data;\r\n return categoriesResponse.data;\r\n\r\n }, function (error) {\r\n throw error;\r\n });\r\n }\r\n\r\n return loadAll().then(function (categories) {\r\n return categories;\r\n }, function (error) {\r\n throw error;\r\n });\r\n }\r\n\r\n\r\n function openCategorySelection(downloader) {\r\n var instance = $uibModal.open({\r\n templateUrl: 'static/html/directives/addable-nzb-modal.html',\r\n controller: 'DownloaderCategorySelectionController',\r\n size: \"sm\",\r\n resolve: {\r\n categories: function () {\r\n return getCategories(downloader)\r\n }\r\n }\r\n });\r\n\r\n instance.result.then(function () {\r\n }, function () {\r\n deferred.reject(\"dismissed\");\r\n }\r\n );\r\n deferred = $q.defer();\r\n return deferred.promise;\r\n }\r\n\r\n function select(category) {\r\n selectedCategory = category;\r\n\r\n deferred.resolve(category);\r\n }\r\n\r\n function invalidate() {\r\n categories = {};\r\n }\r\n}\r\n\r\nangular\r\n .module('nzbhydraApp').controller('DownloaderCategorySelectionController', [\"$scope\", \"$uibModalInstance\", \"DownloaderCategoriesService\", \"categories\", function ($scope, $uibModalInstance, DownloaderCategoriesService, categories) {\r\n\r\n $scope.categories = categories;\r\n categories.sort();\r\n console.log(categories);\r\n $scope.select = function (category) {\r\n DownloaderCategoriesService.select(category);\r\n $uibModalInstance.close($scope);\r\n }\r\n}]);","\nDownloadHistoryController.$inject = [\"$scope\", \"StatsService\", \"downloads\", \"ConfigService\", \"$timeout\", \"$sce\"];angular\n .module('nzbhydraApp')\n .controller('DownloadHistoryController', DownloadHistoryController);\n\n\nfunction DownloadHistoryController($scope, StatsService, downloads, ConfigService, $timeout, $sce) {\n $scope.limit = 100;\n $scope.pagination = {\n current: 1\n };\n var sortModel = {\n column: \"time\",\n sortMode: 2\n };\n $timeout(function () {\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n }, 10);\n $scope.filterModel = {};\n\n //Filter options\n $scope.indexersForFiltering = [];\n _.forEach(ConfigService.getSafe().indexers, function (indexer) {\n $scope.indexersForFiltering.push({label: indexer.name, id: indexer.name})\n });\n $scope.preselectedTimeInterval = {beforeDate: null, afterDate: null};\n $scope.statusesForFiltering = [\n {label: \"None\", id: 'NONE'},\n {label: \"Requested\", id: 'REQUESTED'},\n {label: \"Internal error\", id: 'INTERNAL_ERROR'},\n {label: \"NZB downloaded successful\", id: 'NZB_DOWNLOAD_SUCCESSFUL'},\n {label: \"NZB download error\", id: 'NZB_DOWNLOAD_ERROR'},\n {label: \"NZB added\", id: 'NZB_ADDED'},\n {label: \"NZB not added\", id: 'NZB_NOT_ADDED'},\n {label: \"NZB add error\", id: 'NZB_ADD_ERROR'},\n {label: \"NZB add rejected\", id: 'NZB_ADD_REJECTED'},\n {label: \"Content download successful\", id: 'CONTENT_DOWNLOAD_SUCCESSFUL'},\n {label: \"Content download warning\", id: 'CONTENT_DOWNLOAD_WARNING'},\n {label: \"Content download error\", id: 'CONTENT_DOWNLOAD_ERROR'}\n ];\n $scope.accessOptionsForFiltering = [{label: \"All\", value: \"all\"}, {label: \"API\", value: 'API'}, {\n label: \"Internal\",\n value: 'INTERNAL'\n }];\n\n //Preloaded data\n $scope.nzbDownloads = downloads.nzbDownloads;\n $scope.totalDownloads = downloads.totalDownloads;\n\n $scope.columnSizes = {\n time: 10,\n indexer: 10,\n title: 37,\n result: 9,\n source: 8,\n age: 6,\n username: 10,\n ip: 10\n };\n var anyUsername = false;\n var anyIp = false;\n for (var download of $scope.nzbDownloads) {\n if (download.username) {\n anyUsername = true;\n }\n if (download.ip) {\n anyIp = true;\n }\n if (anyIp && anyUsername) {\n break;\n }\n }\n\n if (ConfigService.getSafe().logging.historyUserInfoType === \"NONE\" || (!anyUsername && !anyIp)) {\n $scope.columnSizes.username = 0;\n $scope.columnSizes.ip = 0;\n $scope.columnSizes.title += 20;\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"IP\") {\n $scope.columnSizes.username = 0;\n $scope.columnSizes.title += 10;\n } else if (ConfigService.getSafe().logging.historyUserInfoType === \"USERNAME\") {\n $scope.columnSizes.ip = 0;\n $scope.columnSizes.title += 10;\n }\n\n $scope.update = function () {\n StatsService.getDownloadHistory($scope.pagination.current, $scope.limit, $scope.filterModel, sortModel).then(function (downloads) {\n $scope.nzbDownloads = downloads.nzbDownloads;\n $scope.totalDownloads = downloads.totalDownloads;\n });\n };\n\n\n $scope.$on(\"sort\", function (event, column, sortMode) {\n if (sortMode === 0) {\n column = \"time\";\n sortMode = 2;\n }\n sortModel = {\n column: column,\n sortMode: sortMode\n };\n $scope.$broadcast(\"newSortColumn\", sortModel.column, sortModel.sortMode);\n $scope.update();\n });\n\n $scope.getStatusIcon = function (result) {\n var spans;\n if (result === \"NONE\" || result === \"REQUESTED\") {\n spans = ''\n }\n if (result === \"INTERNAL_ERROR\") {\n spans = ''\n }\n if (result === \"INTERNAL_ERROR\") {\n spans = ''\n }\n if (result === 'NZB_DOWNLOAD_SUCCESSFUL') {\n spans = '';\n }\n if (result === 'NZB_DOWNLOAD_ERROR') {\n spans = '';\n }\n if (result === 'NZB_ADDED') {\n spans = '';\n }\n if (result === 'NZB_NOT_ADDED' || result === 'NZB_ADD_ERROR' || result === 'NZB_ADD_REJECTED') {\n spans = '';\n }\n if (result === 'CONTENT_DOWNLOAD_SUCCESSFUL') {\n spans = '';\n }\n if (result === 'CONTENT_DOWNLOAD_ERROR' || result === 'CONTENT_DOWNLOAD_WARNING') {\n spans = '';\n }\n return $sce.trustAsHtml('' + spans + '');\n\n };\n\n\n $scope.$on(\"filter\", function (event, column, filterModel, isActive) {\n if (filterModel.filterValue) {\n $scope.filterModel[column] = filterModel;\n } else {\n delete $scope.filterModel[column];\n }\n $scope.update();\n })\n\n}\n\nangular\n .module('nzbhydraApp')\n .filter('reformatDateEpoch', reformatDateEpoch);\n\nfunction reformatDateEpoch() {\n return function (date) {\n return moment.unix(date).local().format(\"YYYY-MM-DD HH:mm\");\n\n }\n}\n","/*\r\n * (C) Copyright 2017 TheOtherP (theotherp@posteo.net)\r\n *\r\n * Licensed under the Apache License, Version 2.0 (the \"License\");\r\n * you may not use this file except in compliance with the License.\r\n * You may obtain a copy of the License at\r\n *\r\n * http://www.apache.org/licenses/LICENSE-2.0\r\n *\r\n * Unless required by applicable law or agreed to in writing, software\r\n * distributed under the License is distributed on an \"AS IS\" BASIS,\r\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\r\n * See the License for the specific language governing permissions and\r\n * limitations under the License.\r\n */\r\n\r\nDebugService.$inject = [\"$filter\"];\r\nangular\r\n .module('nzbhydraApp')\r\n .factory('DebugService', DebugService);\r\n\r\nfunction DebugService($filter) {\r\n\r\n var debug = {};\r\n\r\n return {\r\n log: log,\r\n print: print\r\n };\r\n\r\n function log(name) {\r\n if (!(name in debug)) {\r\n debug[name] = {first: new Date().getTime(), last: new Date().getTime()};\r\n } else {\r\n debug[name][\"last\"] = new Date().getTime();\r\n }\r\n }\r\n\r\n function print() {\r\n //Re-enable if necessary\r\n // for (var key in debug) {\r\n // if (debug.hasOwnProperty(key)) {\r\n // console.log(\"First \" + key + \": \" + $filter(\"date\")(new Date(debug[key][\"first\"]), \"h:mm:ss:sss\"));\r\n // console.log(\"Last \" + key + \": \" + $filter(\"date\")(new Date(debug[key][\"last\"]), \"h:mm:ss:sss\"));\r\n // console.log(\"Diff: \" + (debug[key][\"last\"] - debug[key][\"first\"]));\r\n // }\r\n // }\r\n }\r\n\r\n\r\n}","\r\nCategoriesService.$inject = [\"ConfigService\"];angular\r\n .module('nzbhydraApp')\r\n .factory('CategoriesService', CategoriesService);\r\n\r\nfunction CategoriesService(ConfigService) {\r\n\r\n return {\r\n getByName: getByName,\r\n getAllCategories: getAllCategories,\r\n getDefault: getDefault,\r\n getWithoutAll: getWithoutAll\r\n };\r\n\r\n\r\n function getByName(name) {\r\n for (var cat in ConfigService.getSafe().categoriesConfig.categories) {\r\n var category = ConfigService.getSafe().categoriesConfig.categories[cat];\r\n if (category.name === name) {\r\n return category;\r\n }\r\n }\r\n }\r\n\r\n function getAllCategories() {\r\n return ConfigService.getSafe().categoriesConfig.categories;\r\n }\r\n\r\n function getWithoutAll() {\r\n var cats = ConfigService.getSafe().categoriesConfig.categories;\r\n return cats.slice(1, cats.length);\r\n }\r\n\r\n function getDefault() {\r\n return getByName(ConfigService.getSafe().categoriesConfig.defaultCategory);\r\n }\r\n\r\n}","\r\nBackupService.$inject = [\"$http\"];angular\r\n .module('nzbhydraApp')\r\n .factory('BackupService', BackupService);\r\n\r\nfunction BackupService($http) {\r\n\r\n return {\r\n getBackupsList: getBackupsList,\r\n restoreFromFile: restoreFromFile\r\n };\r\n\r\n\r\n function getBackupsList() {\r\n return $http.get('internalapi/backup/list').then(function (response) {\r\n return response.data;\r\n });\r\n }\r\n\r\n function restoreFromFile(filename) {\r\n return $http.get('internalapi/backup/restore', {params: {filename: filename}}).then(function (response) {\r\n return response;\r\n });\r\n }\r\n\r\n}","//Copied from https://github.com/oblador/angular-scroll because installing it via bower caused errors\nvar duScrollDefaultEasing = function (x) {\n\n\n if (x < 0.5) {\n return Math.pow(x * 2, 2) / 2;\n }\n return 1 - Math.pow((1 - x) * 2, 2) / 2;\n};\n\nvar duScroll = angular.module('duScroll', [\n 'duScroll.scrollspy',\n 'duScroll.smoothScroll',\n 'duScroll.scrollContainer',\n 'duScroll.spyContext',\n 'duScroll.scrollHelpers'\n])\n//Default animation duration for smoothScroll directive\n .value('duScrollDuration', 350)\n //Scrollspy debounce interval, set to 0 to disable\n .value('duScrollSpyWait', 100)\n //Scrollspy forced refresh interval, use if your content changes or reflows without scrolling.\n //0 to disable\n .value('duScrollSpyRefreshInterval', 0)\n //Wether or not multiple scrollspies can be active at once\n .value('duScrollGreedy', false)\n //Default offset for smoothScroll directive\n .value('duScrollOffset', 0)\n //Default easing function for scroll animation\n .value('duScrollEasing', duScrollDefaultEasing)\n //Which events on the container (such as body) should cancel scroll animations\n .value('duScrollCancelOnEvents', 'scroll mousedown mousewheel touchmove keydown')\n //Whether or not to activate the last scrollspy, when page/container bottom is reached\n .value('duScrollBottomSpy', false)\n //Active class name\n .value('duScrollActiveClass', 'active');\n\nif (typeof module !== 'undefined' && module && module.exports) {\n module.exports = duScroll;\n}\n\n\nangular.module('duScroll.scrollHelpers', ['duScroll.requestAnimation'])\n .run([\"$window\", \"$q\", \"cancelAnimation\", \"requestAnimation\", \"duScrollEasing\", \"duScrollDuration\", \"duScrollOffset\", \"duScrollCancelOnEvents\", function ($window, $q, cancelAnimation, requestAnimation, duScrollEasing, duScrollDuration, duScrollOffset, duScrollCancelOnEvents) {\n 'use strict';\n\n var proto = {};\n\n var isDocument = function (el) {\n return (typeof HTMLDocument !== 'undefined' && el instanceof HTMLDocument) || (el.nodeType && el.nodeType === el.DOCUMENT_NODE);\n };\n\n var isElement = function (el) {\n return (typeof HTMLElement !== 'undefined' && el instanceof HTMLElement) || (el.nodeType && el.nodeType === el.ELEMENT_NODE);\n };\n\n var unwrap = function (el) {\n return isElement(el) || isDocument(el) ? el : el[0];\n };\n\n proto.duScrollTo = function (left, top, duration, easing) {\n var aliasFn;\n if (angular.isElement(left)) {\n aliasFn = this.duScrollToElement;\n } else if (angular.isDefined(duration)) {\n aliasFn = this.duScrollToAnimated;\n }\n if (aliasFn) {\n return aliasFn.apply(this, arguments);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollTo(left, top);\n }\n el.scrollLeft = left;\n el.scrollTop = top;\n };\n\n var scrollAnimation, deferred;\n proto.duScrollToAnimated = function (left, top, duration, easing) {\n if (duration && !easing) {\n easing = duScrollEasing;\n }\n var startLeft = this.duScrollLeft(),\n startTop = this.duScrollTop(),\n deltaLeft = Math.round(left - startLeft),\n deltaTop = Math.round(top - startTop);\n\n var startTime = null, progress = 0;\n var el = this;\n\n var cancelScrollAnimation = function ($event) {\n if (!$event || (progress && $event.which > 0)) {\n if (duScrollCancelOnEvents) {\n el.unbind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n cancelAnimation(scrollAnimation);\n deferred.reject();\n scrollAnimation = null;\n }\n };\n\n if (scrollAnimation) {\n cancelScrollAnimation();\n }\n deferred = $q.defer();\n\n if (duration === 0 || (!deltaLeft && !deltaTop)) {\n if (duration === 0) {\n el.duScrollTo(left, top);\n }\n deferred.resolve();\n return deferred.promise;\n }\n\n var animationStep = function (timestamp) {\n if (startTime === null) {\n startTime = timestamp;\n }\n\n progress = timestamp - startTime;\n var percent = (progress >= duration ? 1 : easing(progress / duration));\n\n el.scrollTo(\n startLeft + Math.ceil(deltaLeft * percent),\n startTop + Math.ceil(deltaTop * percent)\n );\n if (percent < 1) {\n scrollAnimation = requestAnimation(animationStep);\n } else {\n if (duScrollCancelOnEvents) {\n el.unbind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n scrollAnimation = null;\n deferred.resolve();\n }\n };\n\n //Fix random mobile safari bug when scrolling to top by hitting status bar\n el.duScrollTo(startLeft, startTop);\n\n if (duScrollCancelOnEvents) {\n el.bind(duScrollCancelOnEvents, cancelScrollAnimation);\n }\n\n scrollAnimation = requestAnimation(animationStep);\n return deferred.promise;\n };\n\n proto.duScrollToElement = function (target, offset, duration, easing) {\n var el = unwrap(this);\n if (!angular.isNumber(offset) || isNaN(offset)) {\n offset = duScrollOffset;\n }\n var top = this.duScrollTop() + unwrap(target).getBoundingClientRect().top - offset;\n if (isElement(el)) {\n top -= el.getBoundingClientRect().top;\n }\n return this.duScrollTo(0, top, duration, easing);\n };\n\n proto.duScrollLeft = function (value, duration, easing) {\n if (angular.isNumber(value)) {\n return this.duScrollTo(value, this.duScrollTop(), duration, easing);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollX || document.documentElement.scrollLeft || document.body.scrollLeft;\n }\n return el.scrollLeft;\n };\n proto.duScrollTop = function (value, duration, easing) {\n if (angular.isNumber(value)) {\n return this.duScrollTo(this.duScrollLeft(), value, duration, easing);\n }\n var el = unwrap(this);\n if (isDocument(el)) {\n return $window.scrollY || document.documentElement.scrollTop || document.body.scrollTop;\n }\n return el.scrollTop;\n };\n\n proto.duScrollToElementAnimated = function (target, offset, duration, easing) {\n return this.duScrollToElement(target, offset, duration || duScrollDuration, easing);\n };\n\n proto.duScrollTopAnimated = function (top, duration, easing) {\n return this.duScrollTop(top, duration || duScrollDuration, easing);\n };\n\n proto.duScrollLeftAnimated = function (left, duration, easing) {\n return this.duScrollLeft(left, duration || duScrollDuration, easing);\n };\n\n angular.forEach(proto, function (fn, key) {\n angular.element.prototype[key] = fn;\n\n //Remove prefix if not already claimed by jQuery / ui.utils\n var unprefixed = key.replace(/^duScroll/, 'scroll');\n if (angular.isUndefined(angular.element.prototype[unprefixed])) {\n angular.element.prototype[unprefixed] = fn;\n }\n });\n\n }]);\n\n\n//Adapted from https://gist.github.com/paulirish/1579671\nangular.module('duScroll.polyfill', [])\n .factory('polyfill', [\"$window\", function ($window) {\n 'use strict';\n\n var vendors = ['webkit', 'moz', 'o', 'ms'];\n\n return function (fnName, fallback) {\n if ($window[fnName]) {\n return $window[fnName];\n }\n var suffix = fnName.substr(0, 1).toUpperCase() + fnName.substr(1);\n for (var key, i = 0; i < vendors.length; i++) {\n key = vendors[i] + suffix;\n if ($window[key]) {\n return $window[key];\n }\n }\n return fallback;\n };\n }]);\n\nangular.module('duScroll.requestAnimation', ['duScroll.polyfill'])\n .factory('requestAnimation', [\"polyfill\", \"$timeout\", function (polyfill, $timeout) {\n 'use strict';\n\n var lastTime = 0;\n var fallback = function (callback, element) {\n var currTime = new Date().getTime();\n var timeToCall = Math.max(0, 16 - (currTime - lastTime));\n var id = $timeout(function () {\n callback(currTime + timeToCall);\n },\n timeToCall);\n lastTime = currTime + timeToCall;\n return id;\n };\n\n return polyfill('requestAnimationFrame', fallback);\n }])\n .factory('cancelAnimation', [\"polyfill\", \"$timeout\", function (polyfill, $timeout) {\n 'use strict';\n\n var fallback = function (promise) {\n $timeout.cancel(promise);\n };\n\n return polyfill('cancelAnimationFrame', fallback);\n }]);\n\n\nangular.module('duScroll.spyAPI', ['duScroll.scrollContainerAPI'])\n .factory('spyAPI', [\"$rootScope\", \"$timeout\", \"$interval\", \"$window\", \"$document\", \"scrollContainerAPI\", \"duScrollGreedy\", \"duScrollSpyWait\", \"duScrollSpyRefreshInterval\", \"duScrollBottomSpy\", \"duScrollActiveClass\", function ($rootScope, $timeout, $interval, $window, $document, scrollContainerAPI, duScrollGreedy, duScrollSpyWait, duScrollSpyRefreshInterval, duScrollBottomSpy, duScrollActiveClass) {\n 'use strict';\n\n var createScrollHandler = function (context) {\n var timer = false, queued = false;\n var handler = function () {\n queued = false;\n var container = context.container,\n containerEl = container[0],\n containerOffset = 0,\n bottomReached;\n\n if (typeof HTMLElement !== 'undefined' && containerEl instanceof HTMLElement || containerEl.nodeType && containerEl.nodeType === containerEl.ELEMENT_NODE) {\n containerOffset = containerEl.getBoundingClientRect().top;\n bottomReached = Math.round(containerEl.scrollTop + containerEl.clientHeight) >= containerEl.scrollHeight;\n } else {\n var documentScrollHeight = $document[0].body.scrollHeight || $document[0].documentElement.scrollHeight; // documentElement for IE11\n bottomReached = Math.round($window.pageYOffset + $window.innerHeight) >= documentScrollHeight;\n }\n var compareProperty = (duScrollBottomSpy && bottomReached ? 'bottom' : 'top');\n\n var i, currentlyActive, toBeActive, spies, spy, pos;\n spies = context.spies;\n currentlyActive = context.currentlyActive;\n toBeActive = undefined;\n\n for (i = 0; i < spies.length; i++) {\n spy = spies[i];\n pos = spy.getTargetPosition();\n if (!pos || !spy.$element) continue;\n\n if ((duScrollBottomSpy && bottomReached) || (pos.top + spy.offset - containerOffset < 20 && (duScrollGreedy || pos.top * -1 + containerOffset) < pos.height)) {\n //Find the one closest the viewport top or the page bottom if it's reached\n if (!toBeActive || toBeActive[compareProperty] < pos[compareProperty]) {\n toBeActive = {\n spy: spy\n };\n toBeActive[compareProperty] = pos[compareProperty];\n }\n }\n }\n\n if (toBeActive) {\n toBeActive = toBeActive.spy;\n }\n if (currentlyActive === toBeActive || (duScrollGreedy && !toBeActive)) return;\n if (currentlyActive && currentlyActive.$element) {\n currentlyActive.$element.removeClass(duScrollActiveClass);\n $rootScope.$broadcast(\n 'duScrollspy:becameInactive',\n currentlyActive.$element,\n angular.element(currentlyActive.getTargetElement())\n );\n }\n if (toBeActive) {\n toBeActive.$element.addClass(duScrollActiveClass);\n $rootScope.$broadcast(\n 'duScrollspy:becameActive',\n toBeActive.$element,\n angular.element(toBeActive.getTargetElement())\n );\n }\n context.currentlyActive = toBeActive;\n };\n\n if (!duScrollSpyWait) {\n return handler;\n }\n\n //Debounce for potential performance savings\n return function () {\n if (!timer) {\n handler();\n timer = $timeout(function () {\n timer = false;\n if (queued) {\n handler();\n }\n }, duScrollSpyWait, false);\n } else {\n queued = true;\n }\n };\n };\n\n var contexts = {};\n\n var createContext = function ($scope) {\n var id = $scope.$id;\n var context = {\n spies: []\n };\n\n context.handler = createScrollHandler(context);\n contexts[id] = context;\n\n $scope.$on('$destroy', function () {\n destroyContext($scope);\n });\n\n return id;\n };\n\n var destroyContext = function ($scope) {\n var id = $scope.$id;\n var context = contexts[id], container = context.container;\n if (context.intervalPromise) {\n $interval.cancel(context.intervalPromise);\n }\n if (container) {\n container.off('scroll', context.handler);\n }\n delete contexts[id];\n };\n\n var defaultContextId = createContext($rootScope);\n\n var getContextForScope = function (scope) {\n if (contexts[scope.$id]) {\n return contexts[scope.$id];\n }\n if (scope.$parent) {\n return getContextForScope(scope.$parent);\n }\n return contexts[defaultContextId];\n };\n\n var getContextForSpy = function (spy) {\n var context, contextId, scope = spy.$scope;\n if (scope) {\n return getContextForScope(scope);\n }\n //No scope, most likely destroyed\n for (contextId in contexts) {\n context = contexts[contextId];\n if (context.spies.indexOf(spy) !== -1) {\n return context;\n }\n }\n };\n\n var isElementInDocument = function (element) {\n while (element.parentNode) {\n element = element.parentNode;\n if (element === document) {\n return true;\n }\n }\n return false;\n };\n\n var addSpy = function (spy) {\n var context = getContextForSpy(spy);\n if (!context) return;\n context.spies.push(spy);\n if (!context.container || !isElementInDocument(context.container)) {\n if (context.container) {\n context.container.off('scroll', context.handler);\n }\n context.container = scrollContainerAPI.getContainer(spy.$scope);\n if (duScrollSpyRefreshInterval && !context.intervalPromise) {\n context.intervalPromise = $interval(context.handler, duScrollSpyRefreshInterval, 0, false);\n }\n context.container.on('scroll', context.handler).triggerHandler('scroll');\n }\n };\n\n var removeSpy = function (spy) {\n var context = getContextForSpy(spy);\n if (spy === context.currentlyActive) {\n $rootScope.$broadcast('duScrollspy:becameInactive', context.currentlyActive.$element);\n context.currentlyActive = null;\n }\n var i = context.spies.indexOf(spy);\n if (i !== -1) {\n context.spies.splice(i, 1);\n }\n spy.$element = null;\n };\n\n return {\n addSpy: addSpy,\n removeSpy: removeSpy,\n createContext: createContext,\n destroyContext: destroyContext,\n getContextForScope: getContextForScope\n };\n }]);\n\n\nangular.module('duScroll.scrollContainerAPI', [])\n .factory('scrollContainerAPI', [\"$document\", function ($document) {\n 'use strict';\n\n var containers = {};\n\n var setContainer = function (scope, element) {\n var id = scope.$id;\n containers[id] = element;\n return id;\n };\n\n var getContainerId = function (scope) {\n if (containers[scope.$id]) {\n return scope.$id;\n }\n if (scope.$parent) {\n return getContainerId(scope.$parent);\n }\n\n };\n\n var getContainer = function (scope) {\n var id = getContainerId(scope);\n return id ? containers[id] : $document;\n };\n\n var removeContainer = function (scope) {\n var id = getContainerId(scope);\n if (id) {\n delete containers[id];\n }\n };\n\n return {\n getContainerId: getContainerId,\n getContainer: getContainer,\n setContainer: setContainer,\n removeContainer: removeContainer\n };\n }]);\n\n\nangular.module('duScroll.smoothScroll', ['duScroll.scrollHelpers', 'duScroll.scrollContainerAPI'])\n .directive('duSmoothScroll', [\"duScrollDuration\", \"duScrollOffset\", \"scrollContainerAPI\", function (duScrollDuration, duScrollOffset, scrollContainerAPI) {\n 'use strict';\n\n return {\n link: function ($scope, $element, $attr) {\n $element.on('click', function (e) {\n if ((!$attr.href || $attr.href.indexOf('#') === -1) && $attr.duSmoothScroll === '') return;\n\n var id = $attr.href ? $attr.href.replace(/.*(?=#[^\\s]+$)/, '').substring(1) : $attr.duSmoothScroll;\n\n var target = document.getElementById(id) || document.getElementsByName(id)[0];\n if (!target || !target.getBoundingClientRect) return;\n\n if (e.stopPropagation) e.stopPropagation();\n if (e.preventDefault) e.preventDefault();\n\n var offset = $attr.offset ? parseInt($attr.offset, 10) : duScrollOffset;\n var duration = $attr.duration ? parseInt($attr.duration, 10) : duScrollDuration;\n var container = scrollContainerAPI.getContainer($scope);\n\n container.duScrollToElement(\n angular.element(target),\n isNaN(offset) ? 0 : offset,\n isNaN(duration) ? 0 : duration\n );\n });\n }\n };\n }]);\n\n\nangular.module('duScroll.spyContext', ['duScroll.spyAPI'])\n .directive('duSpyContext', [\"spyAPI\", function (spyAPI) {\n 'use strict';\n\n return {\n restrict: 'A',\n scope: true,\n compile: function compile(tElement, tAttrs, transclude) {\n return {\n pre: function preLink($scope, iElement, iAttrs, controller) {\n spyAPI.createContext($scope);\n }\n };\n }\n };\n }]);\n\n\nangular.module('duScroll.scrollContainer', ['duScroll.scrollContainerAPI'])\n .directive('duScrollContainer', [\"scrollContainerAPI\", function (scrollContainerAPI) {\n 'use strict';\n\n return {\n restrict: 'A',\n scope: true,\n compile: function compile(tElement, tAttrs, transclude) {\n return {\n pre: function preLink($scope, iElement, iAttrs, controller) {\n iAttrs.$observe('duScrollContainer', function (element) {\n if (angular.isString(element)) {\n element = document.getElementById(element);\n }\n\n element = (angular.isElement(element) ? angular.element(element) : iElement);\n scrollContainerAPI.setContainer($scope, element);\n $scope.$on('$destroy', function () {\n scrollContainerAPI.removeContainer($scope);\n });\n });\n }\n };\n }\n };\n }]);\n\n\nangular.module('duScroll.scrollspy', ['duScroll.spyAPI'])\n .directive('duScrollspy', [\"spyAPI\", \"duScrollOffset\", \"$timeout\", \"$rootScope\", function (spyAPI, duScrollOffset, $timeout, $rootScope) {\n 'use strict';\n\n var Spy = function (targetElementOrId, $scope, $element, offset) {\n if (angular.isElement(targetElementOrId)) {\n this.target = targetElementOrId;\n } else if (angular.isString(targetElementOrId)) {\n this.targetId = targetElementOrId;\n }\n this.$scope = $scope;\n this.$element = $element;\n this.offset = offset;\n };\n\n Spy.prototype.getTargetElement = function () {\n if (!this.target && this.targetId) {\n this.target = document.getElementById(this.targetId) || document.getElementsByName(this.targetId)[0];\n }\n return this.target;\n };\n\n Spy.prototype.getTargetPosition = function () {\n var target = this.getTargetElement();\n if (target) {\n return target.getBoundingClientRect();\n }\n };\n\n Spy.prototype.flushTargetCache = function () {\n if (this.targetId) {\n this.target = undefined;\n }\n };\n\n return {\n link: function ($scope, $element, $attr) {\n var href = $attr.ngHref || $attr.href;\n var targetId;\n\n if (href && href.indexOf('#') !== -1) {\n targetId = href.replace(/.*(?=#[^\\s]+$)/, '').substring(1);\n } else if ($attr.duScrollspy) {\n targetId = $attr.duScrollspy;\n } else if ($attr.duSmoothScroll) {\n targetId = $attr.duSmoothScroll;\n }\n if (!targetId) return;\n\n // Run this in the next execution loop so that the scroll context has a chance\n // to initialize\n var timeoutPromise = $timeout(function () {\n var spy = new Spy(targetId, $scope, $element, -($attr.offset ? parseInt($attr.offset, 10) : duScrollOffset));\n spyAPI.addSpy(spy);\n\n $scope.$on('$locationChangeSuccess', spy.flushTargetCache.bind(spy));\n var deregisterOnStateChange = $rootScope.$on('$stateChangeSuccess', spy.flushTargetCache.bind(spy));\n $scope.$on('$destroy', function () {\n spyAPI.removeSpy(spy);\n deregisterOnStateChange();\n });\n }, 0, false);\n $scope.$on('$destroy', function () {\n $timeout.cancel(timeoutPromise);\n });\n }\n };\n }]);\n"]} \ No newline at end of file diff --git a/core/ui-src/js/config/config-fields-service.js b/core/ui-src/js/config/config-fields-service.js index d0d51256f..ab51d273c 100644 --- a/core/ui-src/js/config/config-fields-service.js +++ b/core/ui-src/js/config/config-fields-service.js @@ -2168,7 +2168,8 @@ function ConfigFields($injector) { lines: [ "NZBHydra supports sending and displaying notifications for certain events. You can enable notifications for each event by adding entries below.", 'NZBHydra uses Apprise to communicate with the actual notification providers. You need either a) an instance of Apprise API running or b) an Apprise runnable accessible by NZBHydra. Either are not part of NZBHydra.', - "NZBHydra will also show notifications on the GUI if enabled." + "NZBHydra will also show notifications on the GUI if enabled.", + "Only URLs in the form of the http://../notify/ form will work. Each notification requires a non-null value for URL to be enabled, but always uses the Main URL." ] } },